├── .gitignore ├── assets ├── cover.png ├── icon.png └── cover.afphoto ├── pwa ├── favicon-196.png ├── apple-icon-120.png ├── apple-icon-152.png ├── apple-icon-167.png ├── apple-icon-180.png ├── manifest-icon-192.png ├── manifest-icon-512.png ├── apple-splash-1136-640.png ├── apple-splash-1334-750.png ├── apple-splash-1792-828.png ├── apple-splash-640-1136.png ├── apple-splash-750-1334.png ├── apple-splash-828-1792.png ├── apple-splash-1125-2436.png ├── apple-splash-1242-2208.png ├── apple-splash-1242-2688.png ├── apple-splash-1536-2048.png ├── apple-splash-1668-2224.png ├── apple-splash-1668-2388.png ├── apple-splash-2048-1536.png ├── apple-splash-2048-2732.png ├── apple-splash-2208-1242.png ├── apple-splash-2224-1668.png ├── apple-splash-2388-1668.png ├── apple-splash-2436-1125.png ├── apple-splash-2688-1242.png └── apple-splash-2732-2048.png ├── styles ├── fonts │ ├── icomoon.eot │ ├── icomoon.ttf │ ├── icomoon.woff │ └── icomoon.svg ├── reset.css ├── fonts.css └── style.css ├── src ├── palette │ ├── dither-palette.png │ ├── dither-palette-legacy.png │ └── raw_colors.js ├── index.js ├── utils │ ├── point_direction.js │ └── flood_fill.js ├── tools │ ├── flip.js │ ├── mirror.js │ ├── resize.js │ ├── stretch.js │ ├── base.js │ ├── eyedropper.js │ ├── eraser.js │ ├── fill.js │ └── paint.js ├── view │ ├── header │ │ ├── toolbox.js │ │ ├── brush_shapes.js │ │ └── resize_modal.js │ ├── components │ │ ├── modal.js │ │ ├── tool_button.js │ │ └── dropdown.js │ ├── sidebar │ │ ├── undo_redo.js │ │ └── brush_options.js │ ├── about_modal.js │ ├── settings_modal.js │ ├── palette-pixi.js │ └── layout.js ├── frame │ ├── keyboard.js │ ├── tools.js │ ├── autosave.js │ ├── undo_stack.js │ └── artwork.js ├── foundation │ └── store.js ├── shaders │ └── shader.frag ├── app.js └── vendor │ ├── preact.js │ ├── rgbquant.js │ └── pixi-viewport.js ├── CHANGELOG.md ├── manifest.json ├── tools ├── compress.js ├── zip.js └── gen-vendor.js ├── package.json ├── LICENSE ├── README.md └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | .cache/ 4 | dist/ -------------------------------------------------------------------------------- /assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/assets/cover.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/assets/icon.png -------------------------------------------------------------------------------- /pwa/favicon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/favicon-196.png -------------------------------------------------------------------------------- /assets/cover.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/assets/cover.afphoto -------------------------------------------------------------------------------- /pwa/apple-icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-icon-120.png -------------------------------------------------------------------------------- /pwa/apple-icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-icon-152.png -------------------------------------------------------------------------------- /pwa/apple-icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-icon-167.png -------------------------------------------------------------------------------- /pwa/apple-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-icon-180.png -------------------------------------------------------------------------------- /styles/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/styles/fonts/icomoon.eot -------------------------------------------------------------------------------- /styles/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/styles/fonts/icomoon.ttf -------------------------------------------------------------------------------- /pwa/manifest-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/manifest-icon-192.png -------------------------------------------------------------------------------- /pwa/manifest-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/manifest-icon-512.png -------------------------------------------------------------------------------- /styles/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/styles/fonts/icomoon.woff -------------------------------------------------------------------------------- /pwa/apple-splash-1136-640.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1136-640.png -------------------------------------------------------------------------------- /pwa/apple-splash-1334-750.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1334-750.png -------------------------------------------------------------------------------- /pwa/apple-splash-1792-828.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1792-828.png -------------------------------------------------------------------------------- /pwa/apple-splash-640-1136.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-640-1136.png -------------------------------------------------------------------------------- /pwa/apple-splash-750-1334.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-750-1334.png -------------------------------------------------------------------------------- /pwa/apple-splash-828-1792.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-828-1792.png -------------------------------------------------------------------------------- /pwa/apple-splash-1125-2436.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1125-2436.png -------------------------------------------------------------------------------- /pwa/apple-splash-1242-2208.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1242-2208.png -------------------------------------------------------------------------------- /pwa/apple-splash-1242-2688.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1242-2688.png -------------------------------------------------------------------------------- /pwa/apple-splash-1536-2048.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1536-2048.png -------------------------------------------------------------------------------- /pwa/apple-splash-1668-2224.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1668-2224.png -------------------------------------------------------------------------------- /pwa/apple-splash-1668-2388.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-1668-2388.png -------------------------------------------------------------------------------- /pwa/apple-splash-2048-1536.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2048-1536.png -------------------------------------------------------------------------------- /pwa/apple-splash-2048-2732.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2048-2732.png -------------------------------------------------------------------------------- /pwa/apple-splash-2208-1242.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2208-1242.png -------------------------------------------------------------------------------- /pwa/apple-splash-2224-1668.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2224-1668.png -------------------------------------------------------------------------------- /pwa/apple-splash-2388-1668.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2388-1668.png -------------------------------------------------------------------------------- /pwa/apple-splash-2436-1125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2436-1125.png -------------------------------------------------------------------------------- /pwa/apple-splash-2688-1242.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2688-1242.png -------------------------------------------------------------------------------- /pwa/apple-splash-2732-2048.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/pwa/apple-splash-2732-2048.png -------------------------------------------------------------------------------- /src/palette/dither-palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/src/palette/dither-palette.png -------------------------------------------------------------------------------- /src/palette/dither-palette-legacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmliao/strike/HEAD/src/palette/dither-palette-legacy.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { html, render } from './vendor/preact.js' 2 | 3 | import App from './app.js'; 4 | 5 | render(html`<${App} />`, document.getElementById("ui")); -------------------------------------------------------------------------------- /src/utils/point_direction.js: -------------------------------------------------------------------------------- 1 | const radtodeg = function(radians) { 2 | return radians * 180 / Math.PI; 3 | } 4 | 5 | export const point_direction = (x1, y1, x2, y2) => { 6 | return -radtodeg(Math.atan(y1 - y2, x1 - x2)) + 180; 7 | } -------------------------------------------------------------------------------- /src/tools/flip.js: -------------------------------------------------------------------------------- 1 | // only used for undo / redo 2 | import Tool from './base.js'; 3 | 4 | import storeSingleton from "../foundation/store.js"; 5 | 6 | class Flip extends Tool { 7 | applyOperation(operation, renderer, renderTexture) { 8 | // apply mirror 9 | storeSingleton.publish('flip'); 10 | } 11 | } 12 | 13 | export default Flip; -------------------------------------------------------------------------------- /src/tools/mirror.js: -------------------------------------------------------------------------------- 1 | // only used for undo / redo 2 | import Tool from './base.js'; 3 | 4 | import storeSingleton from "../foundation/store.js"; 5 | 6 | class Mirror extends Tool { 7 | applyOperation(operation, renderer, renderTexture) { 8 | // apply mirror 9 | storeSingleton.publish('mirror'); 10 | } 11 | } 12 | 13 | export default Mirror; -------------------------------------------------------------------------------- /src/tools/resize.js: -------------------------------------------------------------------------------- 1 | // only used for undo / redo 2 | import Tool from './base.js'; 3 | 4 | import storeSingleton from "../foundation/store.js"; 5 | 6 | class Resize extends Tool { 7 | applyOperation(operation, renderer, renderTexture) { 8 | // apply mirror 9 | storeSingleton.publish('resize', operation); 10 | } 11 | } 12 | 13 | export default Resize; -------------------------------------------------------------------------------- /src/tools/stretch.js: -------------------------------------------------------------------------------- 1 | // only used for undo / redo 2 | import Tool from './base.js'; 3 | 4 | import storeSingleton from "../foundation/store.js"; 5 | 6 | class Stretch extends Tool { 7 | applyOperation(operation, renderer, renderTexture) { 8 | // apply mirror 9 | storeSingleton.publish('stretch', operation); 10 | } 11 | } 12 | 13 | export default Stretch; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.0 2 | 3 | * Add keyboard shortcuts for undo and redo. 4 | 5 | # 0.2.1 6 | 7 | * Fix bug where changing brush size and then setting brush size back to 1 would cause it to be offset by a pixel. 8 | 9 | # 0.2.0 10 | 11 | * Added `image-rendering: optimizeSpeed;` to the canvas CSS so that the canvas remains sharp on Firefox 12 | * Updated both artwork and palette to not auto-refresh per frame, instead manually redrawing for performance purposes. 13 | * Gracefully degrade autosaving if browser doesn't have access to localStorage. 14 | 15 | # 0.1.0 16 | 17 | Initial release! -------------------------------------------------------------------------------- /src/view/header/toolbox.js: -------------------------------------------------------------------------------- 1 | import { html, render, useState, useEffect } from '../../vendor/preact.js'; 2 | import { toolId } from '../../frame/tools.js'; 3 | import { ToolButton } from '../components/tool_button.js'; 4 | 5 | const Toolbox = (props) => { 6 | return (html``) 11 | } 12 | 13 | export default Toolbox; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Strike", 3 | "short_name": "Strike", 4 | "start_url": ".", 5 | "display": "fullscreen", 6 | "background_color": "#000", 7 | "theme_color": "#000", 8 | "description": "1-bit paint application", 9 | "icons": [ 10 | { 11 | "src": "pwa/manifest-icon-192.png", 12 | "sizes": "192x192", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "pwa/manifest-icon-512.png", 18 | "sizes": "512x512", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /tools/compress.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { spawnSync } = require('child_process') 4 | 5 | // after regenerating pwa files, we should threshold them to make them take up less space. 6 | const pwa = fs.readdirSync(path.resolve(__dirname, '..', 'pwa')) 7 | 8 | for (let file of pwa) { 9 | console.log(file) 10 | const filePath = path.resolve(path.resolve(__dirname, '..', 'pwa'), file) 11 | 12 | spawnSync('gm', ['convert', filePath, '-dither', '-monochrome', filePath]) 13 | } 14 | 15 | // I run 16 | // pngquant ./pwa/*.png --ext .png -f 17 | // afterwards to compress images down further. 18 | // too lazy to add it to this script, though... -------------------------------------------------------------------------------- /tools/zip.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const { spawnSync } = require('child_process') 4 | 5 | const buildDir = path.resolve(__dirname, '..', 'build'); 6 | if (!fs.existsSync(buildDir)) { 7 | fs.mkdirSync(buildDir, { recursive: true }) 8 | } 9 | 10 | const buildPath = path.resolve(buildDir, 'build.zip') 11 | if (fs.existsSync(buildPath)) { 12 | fs.unlinkSync(buildPath) 13 | } 14 | 15 | const zip = spawnSync('7z', ['a', '-tzip', path.resolve(buildPath), 16 | path.resolve(__dirname, '..', 'index.html'), 17 | path.resolve(__dirname, '..', 'src'), 18 | path.resolve(__dirname, '..', 'pwa'), 19 | path.resolve(__dirname, '..', 'styles') 20 | ]); 21 | 22 | console.log(zip.stdout.toString()) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strike", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Sorry, no automated tests (yet...?)\" && exit 1", 8 | "vendor": "node tools/gen-vendor.js", 9 | "zip": "node tools/zip.js", 10 | "pwa": "pwa-asset-generator assets/icon.png ./pwa --favicon -i ./index.html -m ./manifest.json -b \"rgba(0,0,0,1)\"" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "htm": "^3.0.4", 16 | "pixi-viewport": "^4.11.0", 17 | "pixi.js": "^5.2.2", 18 | "preact": "^10.4.1", 19 | "rgbquant": "^1.1.2" 20 | }, 21 | "devDependencies": { 22 | "pwa-asset-generator": "^2.3.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/palette/raw_colors.js: -------------------------------------------------------------------------------- 1 | // 16 colors, all of which are very visually distinct so that the shader can later on work its magic 2 | export const colors = [ 3 | 0x000000, 4 | 0x111111, 5 | 0x222222, 6 | 0x333333, 7 | 0x444444, 8 | 0x555555, 9 | 0x666666, 10 | 0x777777, 11 | 0x888888, 12 | 0x999999, 13 | 0xAAAAAA, 14 | 0xBBBBBB, 15 | 0xCCCCCC, 16 | 0xDDDDDD, 17 | 0xEEEEEE, 18 | 0xFFFFFF 19 | ]; 20 | 21 | export const rgbQuantColors = () => { 22 | const _toColor = (num) => { 23 | num >>>= 0; 24 | var b = num & 0xFF, 25 | g = (num & 0xFF00) >>> 8, 26 | r = (num & 0xFF0000) >>> 16; 27 | return [r, g, b]; 28 | } 29 | 30 | const q = []; 31 | for (let color of colors) { 32 | q.push(_toColor(color)); 33 | } 34 | 35 | return q; 36 | } -------------------------------------------------------------------------------- /src/view/components/modal.js: -------------------------------------------------------------------------------- 1 | import { html, useState, useEffect } from '../../vendor/preact.js'; 2 | import store from '../../foundation/store.js'; 3 | export const Modal = (props) => { 4 | const [isVisible, setIsVisible] = useState(false); 5 | useEffect(() => { 6 | store.listen('modal_show', (id) => { 7 | if (id === props.id) { 8 | setIsVisible(true); 9 | } else { 10 | setIsVisible(false); 11 | } 12 | }) 13 | 14 | store.listen('modal_hide', () => { 15 | setIsVisible(false); 16 | }) 17 | }, []) 18 | 19 | const onClickOverlay = () => { 20 | store.publish('modal_hide'); 21 | } 22 | 23 | if (!isVisible) { 24 | return null; 25 | } 26 | 27 | return (html` 28 | `) 31 | } 32 | -------------------------------------------------------------------------------- /src/view/sidebar/undo_redo.js: -------------------------------------------------------------------------------- 1 | import { html, render, useState, useEffect } from '../../vendor/preact.js'; 2 | import { IconButton } from '../components/tool_button.js'; 3 | import store from '../../foundation/store.js'; 4 | 5 | const UndoRedo = (props) => { 6 | let [canUndo, setCanUndo] = useState(store.get('can_undo')); 7 | let [canRedo, setCanRedo] = useState(store.get('can_redo')); 8 | 9 | useEffect(() => { 10 | store.subscribe('can_undo', (can) => { 11 | setCanUndo(can); 12 | }) 13 | store.subscribe('can_redo', (can) => { 14 | setCanRedo(can); 15 | }) 16 | }, []); 17 | 18 | return (html`
19 | <${IconButton} disabled=${!canUndo} icon="icon-reply" onclick=${() => { 20 | store.publish('undo'); 21 | }} /> 22 | <${IconButton} disabled=${!canRedo} icon="icon-forward" onclick=${() => { 23 | store.publish('redo'); 24 | }} /> 25 |
`) 26 | } 27 | 28 | export default UndoRedo; -------------------------------------------------------------------------------- /tools/gen-vendor.js: -------------------------------------------------------------------------------- 1 | // run in node 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | const vendorPackages = { 6 | 'pixi.js': 'pixi.js/dist/pixi.min.js', 7 | 'pixi.min.js.map': 'pixi.js/dist/pixi.min.js.map', 8 | 'pixi-viewport.js': 'pixi-viewport/dist/viewport.js', 9 | 'viewport.js.map': 'pixi-viewport/dist/viewport.js.map', 10 | 'preact.js': 'htm/preact/standalone.mjs', 11 | 'rgbquant.js': 'rgbquant/src/rgbquant.js' 12 | } 13 | 14 | // clear vendor folder 15 | const vendorFolder = path.resolve(__dirname, '..', 'src', 'vendor') 16 | if (fs.existsSync(vendorFolder)) { 17 | fs.rmdirSync(vendorFolder, { recursive: true}); 18 | } 19 | fs.mkdirSync(vendorFolder, { recursive: true }) 20 | 21 | for (let package in vendorPackages) { 22 | const packagePath = path.resolve(__dirname, '..', 'node_modules', vendorPackages[package]); 23 | fs.copyFileSync(packagePath, path.resolve(vendorFolder, package)); 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/frame/keyboard.js: -------------------------------------------------------------------------------- 1 | import store from '../foundation/store.js' 2 | 3 | const setupKeyboardShortcuts = () => { 4 | const shortcuts = { 5 | 'ctrl-z': () => { 6 | if (store.get('can_undo')) { 7 | store.publish('undo') 8 | } 9 | }, 10 | 'ctrl-shift-z': () => { 11 | if (store.get('can_redo')) { 12 | store.publish('redo') 13 | } 14 | }, 15 | 'ctrl-y': () => { 16 | if (store.get('can_redo')) { 17 | store.publish('redo') 18 | } 19 | }, 20 | } 21 | document.addEventListener('keydown', (e) => { 22 | let keySequence = '' 23 | 24 | // metakey to get mac os cmd to work 25 | // see https://stackoverflow.com/a/3922353 26 | if (e.ctrlKey || e.metaKey) { 27 | keySequence += 'ctrl-' 28 | } 29 | 30 | if (e.shiftKey) { 31 | keySequence += 'shift-' 32 | } 33 | 34 | if (e.key) { 35 | keySequence += e.key.toLowerCase() 36 | } 37 | 38 | if (shortcuts[keySequence]) { 39 | shortcuts[keySequence]() 40 | } 41 | }) 42 | } 43 | 44 | export default setupKeyboardShortcuts 45 | -------------------------------------------------------------------------------- /src/frame/tools.js: -------------------------------------------------------------------------------- 1 | 2 | import Paint from '../tools/paint.js'; 3 | import Eraser from '../tools/eraser.js'; 4 | import Fill from '../tools/fill.js'; 5 | import Mirror from '../tools/mirror.js'; 6 | import Flip from '../tools/flip.js'; 7 | import Stretch from '../tools/stretch.js'; 8 | import Resize from '../tools/resize.js'; 9 | import Eyedropper from '../tools/eyedropper.js'; 10 | 11 | class Tools { 12 | constructor() { 13 | this.toolObjects = { 14 | PAINT: new Paint(), 15 | ERASER: new Eraser(), 16 | FILL: new Fill(), 17 | EYEDROPPER: new Eyedropper(), 18 | MIRROR: new Mirror(), 19 | FLIP: new Flip(), 20 | STRETCH: new Stretch(), 21 | RESIZE: new Resize(), 22 | } 23 | } 24 | get(toolName) { 25 | return this.toolObjects[toolName]; 26 | } 27 | } 28 | 29 | export const toolId = { 30 | PAINT: 'PAINT', 31 | ERASER: 'ERASER', 32 | FILL: 'FILL', 33 | MOVE: 'MOVE', 34 | EYEDROPPER: 'EYEDROPPER', 35 | } 36 | 37 | // singleton 38 | const tools = new Tools(); 39 | Object.freeze(tools); 40 | 41 | export default tools; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Amorphous 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/view/components/tool_button.js: -------------------------------------------------------------------------------- 1 | import { html, useState, useEffect } from '../../vendor/preact.js'; 2 | import store from '../../foundation/store.js'; 3 | 4 | 5 | export const IconButton = (props) => { 6 | let buttonClass = 'tool-button' 7 | 8 | if (props.active) { 9 | buttonClass += ' active'; 10 | } 11 | if (props.disabled) { 12 | buttonClass += ' disabled'; 13 | } 14 | 15 | return (html`
`) 16 | } 17 | 18 | export const ToolButton = (props) => { 19 | const tool = props.tool; 20 | const icon = props.icon; 21 | 22 | let [active, setActive] = useState(store.get('tool') === tool); 23 | 24 | useEffect(() => { 25 | store.subscribe('tool', (newTool) => { 26 | setActive(tool === newTool); 27 | }); 28 | }, []) 29 | 30 | return (html`<${IconButton} title=${props.title} active=${active} icon=${icon} onclick=${() => { 31 | store.update('tool', tool); 32 | }}/>`) 33 | } 34 | -------------------------------------------------------------------------------- /src/view/about_modal.js: -------------------------------------------------------------------------------- 1 | import { html, useState, useEffect } from '../vendor/preact.js' 2 | import { Modal } from './components/modal.js' 3 | import storeSingleton from '../foundation/store.js' 4 | 5 | const AboutModal = (props) => { 6 | return html`<${Modal} id="about"> 7 |

Strike v0.3.0

8 |

a 1-bit painting app

9 |

written by Amorphous

10 |
11 |

Usage Notes:

12 | 20 |
21 |

22 | Icons: Feather by Cole Bemis and 23 | Entypo by Daniel Bruce 24 |

25 |
26 | 33 | ` 34 | } 35 | 36 | export default AboutModal 37 | -------------------------------------------------------------------------------- /src/tools/base.js: -------------------------------------------------------------------------------- 1 | class Tool { 2 | constructor() { 3 | this.toolType = "UNKNOWN"; 4 | } 5 | // renderer - the renderer to use 6 | // renderTexture = the texture to render to 7 | // event - pointer event 8 | // artwork - artwork object. We can get viewport, undo manager, etc. from it. 9 | begin(renderer, renderTexture, event, artwork) { 10 | 11 | } 12 | 13 | move(renderer, renderTexture, event, artwork) { 14 | // called when dragging the mouse 15 | } 16 | 17 | end(renderer, renderTexture, event, artwork) { 18 | 19 | } 20 | 21 | // these functions map the global event position x and y to the correct position 22 | // on a viewport, given the viewport's translation and scaling. 23 | getX(event, viewport) { 24 | if (!viewport) { 25 | return Math.floor(event.data.global.x); 26 | } 27 | 28 | return Math.floor(event.data.global.x / viewport.scaled + viewport.corner.x) 29 | } 30 | 31 | getY(event, viewport) { 32 | if (!viewport) { 33 | return Math.floor(event.data.global.y); 34 | } 35 | 36 | return Math.floor(event.data.global.y / viewport.scaled + viewport.corner.y) 37 | } 38 | 39 | applyOperation(operation, renderer, renderTexture) { 40 | console.log('ERROR: undo and redo not yet implemented for this tool', operation); 41 | } 42 | } 43 | 44 | export default Tool; -------------------------------------------------------------------------------- /styles/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } -------------------------------------------------------------------------------- /src/tools/eyedropper.js: -------------------------------------------------------------------------------- 1 | import Tool from "./base.js"; 2 | import storeSingleton from "../foundation/store.js"; 3 | import { toolId } from "../frame/tools.js"; 4 | 5 | class Eyedropper extends Tool { 6 | constructor() { 7 | super(); 8 | this.color = storeSingleton.get('color') || 0xffffff; 9 | 10 | storeSingleton.subscribe('color', (color) => { 11 | this.color = color; 12 | }); 13 | } 14 | 15 | end(renderer, renderTexture, event, artwork) { 16 | const viewport = artwork.getViewport(); 17 | 18 | const id = renderer.extract.pixels(renderTexture); 19 | 20 | const x = this.getX(event, viewport) 21 | const y = this.getY(event, viewport) 22 | let pixel_pos = (y*renderTexture.width + x) * 4; 23 | const r = id[pixel_pos+0]; 24 | const g = id[pixel_pos+1]; 25 | const b = id[pixel_pos+2]; 26 | const a = id[pixel_pos+3]; 27 | 28 | storeSingleton.update('color', this._fromColor(r,g,b,256)) 29 | storeSingleton.update('tool', toolId.PAINT) 30 | } 31 | 32 | _fromColor(red, green, blue, alpha) { 33 | var r = red & 0xFF; 34 | var g = green & 0xFF; 35 | var b = blue & 0xFF; 36 | var a = alpha & 0xFF; 37 | 38 | // var rgb = (r << 24 >>> 0) + (g << 16 >>> 0) + (b << 8 >>> 0) + (a); 39 | var rgb = (r << 16 >>> 0) + (g << 8 >>> 0) + (b >>> 0); 40 | return rgb 41 | } 42 | } 43 | 44 | export default Eyedropper; -------------------------------------------------------------------------------- /src/tools/eraser.js: -------------------------------------------------------------------------------- 1 | import Paint from './paint.js' 2 | 3 | import storeSingleton from "../foundation/store.js"; 4 | import { toolId } from '../frame/tools.js'; 5 | 6 | class Eraser extends Paint { 7 | constructor() { 8 | super(); 9 | this.blendMode = PIXI.BLEND_MODES.DST_OUT; 10 | this.toolType = toolId.ERASER; 11 | this.subscribe(); 12 | this.updateBrush(); 13 | } 14 | 15 | subscribe() { 16 | this.brushSize = storeSingleton.get('eraser.size') || 1; 17 | storeSingleton.subscribe('eraser.size', (newSize) => { 18 | this.updateBrush({ 19 | size: newSize 20 | }); 21 | }) 22 | 23 | this.brushShape = storeSingleton.get('eraser.shape') || 'flat'; 24 | storeSingleton.subscribe('eraser.shape', (newShape) => { 25 | this.updateBrush({ 26 | shape: newShape 27 | }); 28 | }) 29 | 30 | storeSingleton.update('eraser.shape', this.brushShape); 31 | } 32 | 33 | updateBrush({size, shape, color} = {}) { 34 | super.updateBrush({size, shape, color}); 35 | if (this.blendMode) { 36 | this.brush.blendMode = this.blendMode; 37 | this.lineBrush.blendMode = this.blendMode; 38 | } 39 | } 40 | 41 | _updateBrushTemporary(size, shape, color) { 42 | super._updateBrushTemporary(size, shape, color); 43 | if (this.blendMode) { 44 | this.brush.blendMode = this.blendMode; 45 | this.lineBrush.blendMode = this.blendMode; 46 | } 47 | } 48 | } 49 | 50 | export default Eraser; -------------------------------------------------------------------------------- /src/tools/fill.js: -------------------------------------------------------------------------------- 1 | import Tool from "./base.js"; 2 | import { flood_fill } from '../utils/flood_fill.js'; 3 | import storeSingleton from "../foundation/store.js"; 4 | import { toolId } from "../frame/tools.js"; 5 | 6 | class Fill extends Tool { 7 | constructor() { 8 | super(); 9 | this.color = storeSingleton.get('color') || 0xffffff; 10 | this.toolType = toolId.FILL; 11 | 12 | storeSingleton.subscribe('color', (color) => { 13 | this.color = color; 14 | }); 15 | } 16 | 17 | end(renderer, renderTexture, event, artwork) { 18 | const viewport = artwork.getViewport(); 19 | 20 | const splitColor = this._toColor(this.color); 21 | 22 | // we don't have any alpha support in this tool, so we can automatically set alpha to max, which is 255. 23 | flood_fill(renderer, renderTexture, this.getX(event, viewport), this.getY(event, viewport), splitColor[0], splitColor[1], splitColor[2], 255); 24 | 25 | artwork.addUndoable(this.toolType, { 26 | color: this.color, 27 | x: this.getX(event, viewport), 28 | y: this.getY(event, viewport), 29 | }); 30 | } 31 | 32 | _toColor(num) { 33 | num >>>= 0; 34 | var b = num & 0xFF, 35 | g = (num & 0xFF00) >>> 8, 36 | r = (num & 0xFF0000) >>> 16, 37 | a = ( (num & 0xFF000000) >>> 24 ); 38 | return [r, g, b, a]; 39 | } 40 | 41 | applyOperation(operation, renderer, renderTexture) { 42 | const splitColor = this._toColor(operation.color); 43 | flood_fill(renderer, renderTexture, operation.x, operation.y, splitColor[0], splitColor[1], splitColor[2], 255); 44 | } 45 | } 46 | 47 | export default Fill; -------------------------------------------------------------------------------- /src/view/components/dropdown.js: -------------------------------------------------------------------------------- 1 | import { html, useState, useEffect } from '../../vendor/preact.js'; 2 | import store from '../../foundation/store.js'; 3 | 4 | export const DropdownItem = (props) => { 5 | const onclick = () => { 6 | if (props.onclick) { 7 | props.onclick(); 8 | } 9 | 10 | store.publish('hide_dropdown') 11 | } 12 | return (html``) 17 | } 18 | 19 | export const DropdownButton = (props) => { 20 | useEffect(() => { 21 | store.listen('hide_dropdown', () => { 22 | setShowDropdown(false) 23 | }); 24 | }, []) 25 | const [showDropdown, setShowDropdown] = useState(false); 26 | const onMouseEnter = () => { 27 | if (!props.showOnHover) { 28 | return; 29 | } 30 | store.publish('hide_dropdown') 31 | setShowDropdown(true); 32 | } 33 | const onMouseLeave = () => { 34 | if (!props.hideOnLeave) { 35 | return; 36 | } 37 | setShowDropdown(false); 38 | } 39 | 40 | const onButtonClick = () => { 41 | if (!showDropdown) { 42 | store.publish('hide_dropdown'); 43 | } 44 | setShowDropdown(!showDropdown); 45 | } 46 | 47 | let style = ''; 48 | if (!showDropdown) { 49 | style = "display: none" 50 | } 51 | 52 | return (html``) 58 | } -------------------------------------------------------------------------------- /src/view/sidebar/brush_options.js: -------------------------------------------------------------------------------- 1 | import { html, render, useState, useEffect } from '../../vendor/preact.js'; 2 | import store from '../../foundation/store.js'; 3 | import { toolId } from '../../frame/tools.js'; 4 | 5 | const BrushSizeSlider = () => { 6 | let [tool, setTool] = useState(store.get('tool')) 7 | let [size, setSize] = useState(1); 8 | 9 | useEffect(() => { 10 | store.subscribe('tool', (newTool) => { 11 | setTool(newTool); 12 | if (newTool === toolId.PAINT) { 13 | setSize(store.get('paint.size') || 1) 14 | } else if (newTool === toolId.ERASER) { 15 | setSize(store.get('eraser.size') || 1) 16 | } 17 | }); 18 | }, []) 19 | 20 | return(html`
21 | { 22 | const newSize = parseInt(document.getElementById("brush-size-slider").value, 10); 23 | if (tool === toolId.PAINT) { 24 | store.update('paint.size', newSize) 25 | } else if (tool === toolId.ERASER) { 26 | store.update('eraser.size', newSize) 27 | } 28 | setSize(newSize) 29 | }} /> 30 |
31 | 32 |
`) 33 | } 34 | 35 | const BrushOptions = () => { 36 | let [tool, setTool] = useState(store.get('tool')) 37 | useEffect(() => { 38 | store.subscribe('tool', (newTool) => { 39 | setTool(newTool); 40 | }); 41 | }, []) 42 | 43 | let view_style = ''; 44 | if (tool !== toolId.PAINT && tool !== toolId.ERASER) { 45 | view_style = "visibility: hidden" 46 | } 47 | return (html`
48 | <${BrushSizeSlider} /> 49 |
`) 50 | } 51 | 52 | export default BrushOptions; -------------------------------------------------------------------------------- /src/foundation/store.js: -------------------------------------------------------------------------------- 1 | class Store { 2 | constructor() { 3 | this.data = {}; 4 | this.callbacks = {}; 5 | this.messages = {}; 6 | } 7 | 8 | get(path) { 9 | const splitPath = path.split('.'); 10 | let pointer = this.data; 11 | for (let i = 0; i < splitPath.length; i++) { 12 | const nextValue = splitPath[i]; 13 | if (pointer[nextValue] === undefined || pointer[nextValue] === null) { 14 | return undefined; 15 | } 16 | pointer = pointer[nextValue]; 17 | } 18 | return pointer; 19 | } 20 | 21 | update(path, value) { 22 | const splitPath = path.split('.'); 23 | let pointer = this.data; 24 | let pathToNow = ''; 25 | for (let i = 0; i < splitPath.length - 1; i++) { 26 | const nextValue = splitPath[i]; 27 | if (!pointer[nextValue]) { 28 | pointer[nextValue] = {}; 29 | } 30 | // update all dependent paths 31 | pathToNow += '.' + nextValue; 32 | this._fire(pathToNow); 33 | pointer = pointer[nextValue]; 34 | } 35 | pointer[splitPath[splitPath.length - 1]] = value; 36 | this._fire(path); 37 | } 38 | 39 | subscribe(path, callback) { 40 | if (!this.callbacks[path]) { 41 | this.callbacks[path] = []; 42 | } 43 | this.callbacks[path].push(callback); 44 | } 45 | 46 | _fire(path) { 47 | if (!this.callbacks[path]) { 48 | return; 49 | } 50 | 51 | for (const callback of this.callbacks[path]) { 52 | callback(this.get(path)) 53 | } 54 | } 55 | 56 | // action ferrying 57 | listen(message, callback) { 58 | if (!this.messages[message]) { 59 | this.messages[message] = []; 60 | } 61 | this.messages[message].push(callback); 62 | } 63 | 64 | publish(message, args) { 65 | if (!this.messages[message]) { 66 | return; 67 | } 68 | 69 | for (const callback of this.messages[message]) { 70 | callback(args) 71 | } 72 | } 73 | } 74 | 75 | const storeSingleton = new Store(); 76 | Object.freeze(storeSingleton); 77 | 78 | export default storeSingleton; -------------------------------------------------------------------------------- /src/view/settings_modal.js: -------------------------------------------------------------------------------- 1 | import { html, useState, useEffect } from '../vendor/preact.js'; 2 | import { Modal } from './components/modal.js'; 3 | import storeSingleton from '../foundation/store.js'; 4 | 5 | const NotSupportedComponent = (html`

Strike doesn't have access to localStorage, so autosaving is not supported.

`); 6 | const SupportedComponent = (html`

If autosaves are enabled, Strike will save the last edited canvas and load it when Strike is started up again. Creating a new canvas replaces the autosaved one.

7 |

Disabling autosave will clear the currently saved canvas.

`); 8 | 9 | const SettingsModal = (props) => { 10 | const [isChecked, setIsChecked] = useState(storeSingleton.get('autosave_enabled')); 11 | const [isAutosaveSupported, setIsAutosaveSupported] = useState(storeSingleton.get('can_autosave')); 12 | 13 | useEffect(() => { 14 | storeSingleton.subscribe('autosave_enabled', (isEnabled) => { 15 | setIsChecked(isEnabled); 16 | }) 17 | 18 | storeSingleton.subscribe('can_autosave', (canAutosave) => { 19 | setIsAutosaveSupported(canAutosave); 20 | }) 21 | }, []) 22 | 23 | const onCheckbox = () => { 24 | if (!isAutosaveSupported) { 25 | return; 26 | } 27 | const autosaveValue = document.getElementById('autosave-checkbox').checked; 28 | storeSingleton.update('autosave_enabled', autosaveValue); 29 | } 30 | 31 | return (html`<${Modal} id="settings"> 32 |

Settings

33 |
34 | 36 | 37 |
38 | ${isAutosaveSupported ? SupportedComponent : NotSupportedComponent} 39 |
40 | 43 | `) 44 | } 45 | 46 | export default SettingsModal -------------------------------------------------------------------------------- /src/shaders/shader.frag: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | varying vec2 vTextureCoord;//The coordinates of the current pixel 4 | 5 | uniform sampler2D uSampler;//The image data 6 | 7 | // need highp to avoid compiler issues 8 | // see https://www.html5gamedevs.com/topic/42235-how-to-get-correct-fragment-shader-uv-in-pixi-50-rc0/ 9 | uniform highp vec4 inputSize; 10 | 11 | uniform sampler2D palette; 12 | uniform float swatchSize; 13 | 14 | // This shader samples a palette, and then use that 15 | // to produce our dither patterns. 16 | void main(void) { 17 | float value = texture2D(uSampler, vTextureCoord).r; 18 | 19 | // TODO: replace these with uniforms 20 | float paletteWidth = 128.0; 21 | float paletteHeight = 8.0; 22 | 23 | // convert normalized input coord to css pixel 24 | // https://github.com/pixijs/pixi.js/wiki/v5-Creating-filters#conversion-functions 25 | vec2 cssPixel = vTextureCoord * inputSize.xy; 26 | 27 | float pixelX = cssPixel.x; 28 | float pixelY = cssPixel.y; 29 | 30 | float positionXinSwatch = mod(pixelX, swatchSize); 31 | float positionYinSwatch = mod(pixelY, swatchSize); 32 | 33 | // we map the greyscale r value to the correct swatch by first converting the 34 | // scale from [0.0, 0.1] to [0, 256] and dividing that out by the number of colors / swatches we have. 35 | // basically a palette remapping, but we really don't care about the in-betweens. 36 | float swatchToUse = value * 256.0 / 16.0; 37 | 38 | // note that this assumes that all of the swatches are in one horizontal strip. 39 | float positionXTotal = positionXinSwatch + swatchSize * floor(swatchToUse); 40 | 41 | vec2 startTextureCoord = vec2(positionXTotal / paletteWidth, positionYinSwatch/paletteHeight); 42 | vec4 sampledPalette = texture2D(palette, startTextureCoord); 43 | 44 | // hard threshold the pixels into black and white. May not be necessary. 45 | if (sampledPalette.r > 0.5) { 46 | gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); 47 | } else { 48 | gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); 49 | } 50 | } -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import { html, useEffect } from './vendor/preact.js' 2 | 3 | import store from './foundation/store.js'; 4 | 5 | import Layout from './view/layout.js'; 6 | import Artwork from './frame/artwork.js'; 7 | import Palette from './view/palette-pixi.js'; 8 | import setupKeyboardShortcuts from './frame/keyboard.js'; 9 | 10 | PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.NEAREST; 11 | PIXI.settings.RENDER_OPTIONS.antialias = false; 12 | PIXI.settings.ROUND_PIXELS = true; 13 | PIXI.settings.PRECISION_FRAGMENT = PIXI.PRECISION.HIGH; 14 | PIXI.settings.MIPMAP_TEXTURES = PIXI.MIPMAP_MODES.OFF; 15 | PIXI.Ticker.system.autoStart = false; 16 | PIXI.Ticker.system.stop() 17 | 18 | const setDocumentTitle = () => { 19 | const dim = store.get('dimensions'); 20 | const isDirtyMarker = store.get('dirty') ? '*' : '' 21 | if (!dim) { 22 | document.title = "Strike"; 23 | return; 24 | } 25 | document.title = "Strike | " + dim.width + "x" + dim.height + isDirtyMarker; 26 | } 27 | 28 | store.subscribe('dimensions', () => { 29 | setDocumentTitle(); 30 | }); 31 | 32 | 33 | store.subscribe('dirty', () => { 34 | setDocumentTitle(); 35 | }); 36 | 37 | // setup brush tool 38 | store.update('eraser.size', 8); 39 | store.update('tool', 'PAINT'); 40 | store.update('color', 0xffffff); 41 | 42 | setupKeyboardShortcuts(); 43 | 44 | // load resources 45 | PIXI.Loader.shared.add('shader', 'src/shaders/shader.frag') 46 | .add('palette', 'src/palette/dither-palette.png') 47 | .load((_loader, res) => { 48 | const texture = res.palette.texture; 49 | const uniforms = { 50 | palette: texture, 51 | swatchSize: 8 52 | } 53 | 54 | const shader = new PIXI.Filter('', res.shader.data, uniforms); 55 | store.update('resources', { shader }); 56 | }); 57 | 58 | const App = (props) => { 59 | useEffect(() => { 60 | const artwork = new Artwork(document.getElementById("main")); 61 | const palette = new Palette(document.getElementById("palette")); 62 | 63 | 64 | }, []) 65 | return (html`<${Layout}> 66 | `); 67 | } 68 | 69 | export default App; -------------------------------------------------------------------------------- /styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: url('fonts/icomoon.eot?fmjxax'); 4 | src: url('fonts/icomoon.eot?fmjxax#iefix') format('embedded-opentype'), 5 | url('fonts/icomoon.ttf?fmjxax') format('truetype'), 6 | url('fonts/icomoon.woff?fmjxax') format('woff'), 7 | url('fonts/icomoon.svg?fmjxax#icomoon') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | font-display: block; 11 | } 12 | 13 | [class^="icon-"], [class*=" icon-"] { 14 | /* use !important to prevent issues with browser extensions that change fonts */ 15 | font-family: 'icomoon' !important; 16 | speak: none; 17 | font-style: normal; 18 | font-weight: normal; 19 | font-variant: normal; 20 | text-transform: none; 21 | line-height: 1; 22 | 23 | /* Better Font Rendering =========== */ 24 | -webkit-font-smoothing: antialiased; 25 | -moz-osx-font-smoothing: grayscale; 26 | } 27 | 28 | .icon-circle:before { 29 | content: "\e90c"; 30 | } 31 | .icon-maximize:before { 32 | content: "\e900"; 33 | } 34 | .icon-move:before { 35 | content: "\e901"; 36 | } 37 | .icon-square:before { 38 | content: "\e90d"; 39 | } 40 | .icon-zoom-in:before { 41 | content: "\e90e"; 42 | } 43 | .icon-zoom-out:before { 44 | content: "\e90f"; 45 | } 46 | .icon-layers:before { 47 | content: "\e902"; 48 | } 49 | .icon-trash:before { 50 | content: "\e903"; 51 | } 52 | .icon-bucket:before { 53 | content: "\e904"; 54 | } 55 | .icon-cog:before { 56 | content: "\e914"; 57 | } 58 | .icon-colours:before { 59 | content: "\e905"; 60 | } 61 | .icon-document:before { 62 | content: "\e910"; 63 | } 64 | .icon-eraser:before { 65 | content: "\e906"; 66 | } 67 | .icon-forward:before { 68 | content: "\e907"; 69 | } 70 | .icon-hair-cross:before { 71 | content: "\e911"; 72 | } 73 | .icon-help:before { 74 | content: "\e912"; 75 | } 76 | .icon-image:before { 77 | content: "\e913"; 78 | } 79 | .icon-palette:before { 80 | content: "\e908"; 81 | } 82 | .icon-popup:before { 83 | content: "\e909"; 84 | } 85 | .icon-reply:before { 86 | content: "\e90a"; 87 | } 88 | .icon-round-brush:before { 89 | content: "\e90b"; 90 | } 91 | -------------------------------------------------------------------------------- /src/view/header/brush_shapes.js: -------------------------------------------------------------------------------- 1 | import { html, render, useState, useEffect } from '../../vendor/preact.js'; 2 | import store from '../../foundation/store.js'; 3 | import { toolId } from '../../frame/tools.js'; 4 | import { IconButton } from '../components/tool_button.js'; 5 | 6 | const BrushShapeButton = (props) => { 7 | let [tool, setTool] = useState(store.get('tool')) 8 | let [isActive, setIsActive] = useState(store.get('paint.shape') === props.shape); 9 | 10 | useEffect(() => { 11 | store.subscribe('tool', (newTool) => { 12 | setTool(newTool); 13 | tool = newTool // I'm not sure why this is needed but it is 14 | if (newTool === toolId.PAINT) { 15 | setIsActive(store.get('paint.shape') === props.shape) 16 | } else if (newTool === toolId.ERASER) { 17 | setIsActive(store.get('eraser.shape') === props.shape) 18 | } 19 | }); 20 | 21 | store.subscribe('paint.shape', (shape) => { 22 | if (tool !== toolId.PAINT) { 23 | return; 24 | } 25 | setIsActive(shape === props.shape) 26 | }); 27 | 28 | store.subscribe('eraser.shape', (shape) => { 29 | if (tool !== toolId.ERASER) { 30 | return; 31 | } 32 | setIsActive(shape === props.shape) 33 | }); 34 | }, []) 35 | 36 | const clickHandler = () => { 37 | if (tool === toolId.PAINT) { 38 | store.update('paint.shape', props.shape) 39 | } else if (tool === toolId.ERASER) { 40 | store.update('eraser.shape', props.shape) 41 | } 42 | } 43 | 44 | let view_style = ''; 45 | if (tool !== toolId.PAINT && tool !== toolId.ERASER) { 46 | view_style = "visibility: hidden" 47 | } 48 | 49 | return (html`<${IconButton} style=${view_style} active=${isActive} icon=${props.icon} title=${props.title} onclick=${clickHandler}/>`) 50 | 51 | } 52 | 53 | const BrushShapes = (props) => { 54 | 55 | return (html``) 60 | } 61 | 62 | export default BrushShapes; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Strike 2 | 3 | A 1-bit painting app with some PWA support, for quick sketching or line drawing. 4 | 5 | ## Features 6 | 7 | * brush / eraser / fill tools with different brush shapes and sizes 8 | * 16-'color' palette via dither patterns. Each pattern is treated as a separate color for fill tool 9 | * ~50 step undo / redo (it's fragile, so don't rely on it too much) 10 | * import PNG and JPG files, which will be converted to 1 bit form 11 | * zoom via mouse scroll or two-finger pinch 12 | * basic image transform tools (flip / mirror / resize canvas) 13 | * save images as a 16-color greyscale (which will import back into Strike with all patterns intact), or as 1-bit black and white image 14 | * autosaving (see notes) 15 | 16 | ## Notes 17 | 18 | Strike is not meant to be a full-featured paint app, so has quite a few limitations. 19 | 20 | * Undo / redo may slow down for large strokes or complex drawings. If they behave strangely, please file a bug report. 21 | * There is a limited autosave feature; by default, starting up Strike will load up the previously drawn image. Creating a new canvas will erase the autosave. You can disable autosaves via the settings (gear) menu. Strike only autosaves one image at a time, so please regularly export your work. 22 | 23 | ## How to build locally 24 | 25 | There is no build step! Unzip the project directory, point your favorite http server at it, and the app will show up on localhost. I tend to use `http-server`: 26 | 27 | ``` 28 | cd path/to/strike/folder 29 | npx http-server . 30 | ``` 31 | 32 | ### What about package.json? 33 | 34 | If you just want to run the app, there's no need to run `npm install` - all the things needed to run the app should already be in the directory. 35 | 36 | However, if you want to edit the app, it might be useful to install dependencies. There are a few helper scripts I wrote for specific functionality: 37 | 38 | **npm run vendor** - takes the minified third-party dependencies from `node_modules` and copies them to the `src/vendor` folder, where they're consumed by the app 39 | 40 | **npm run pwa** - generates splash screens and icons for making Strike into a progressive web app. I usually run `./tools/compress.js` afterwards to further compress the images down. Note that the compress tool requires you to have Graphicsmagick (exposed as `gm`) on the path. 41 | 42 | **npm run zip** - packages the app into a zip file for distribution as a download. Note that this tool assumes you have 7zip (exposed as `7z`) on the path. -------------------------------------------------------------------------------- /src/utils/flood_fill.js: -------------------------------------------------------------------------------- 1 | export const flood_fill = (renderer, renderTexture, start_x, start_y, fill_r, fill_g, fill_b, fill_a) => { 2 | 3 | start_x = Math.floor(start_x); 4 | start_y = Math.floor(start_y); 5 | 6 | // algorithm taken from jspaint 7 | const stack = [[start_x, start_y]]; 8 | const c_width = renderTexture.width; 9 | const c_height = renderTexture.height; 10 | 11 | const id = renderer.extract.pixels(renderTexture); 12 | 13 | let pixel_pos = (start_y*c_width + start_x) * 4; 14 | const start_r = id[pixel_pos+0]; 15 | const start_g = id[pixel_pos+1]; 16 | const start_b = id[pixel_pos+2]; 17 | const start_a = id[pixel_pos+3]; 18 | 19 | if( 20 | fill_r === start_r && 21 | fill_g === start_g && 22 | fill_b === start_b && 23 | fill_a === start_a 24 | ){ 25 | return; 26 | } 27 | 28 | while(stack.length){ 29 | let new_pos; 30 | let x; 31 | let y; 32 | let reach_left; 33 | let reach_right; 34 | new_pos = stack.pop(); 35 | x = new_pos[0]; 36 | y = new_pos[1]; 37 | 38 | pixel_pos = (y*c_width + x) * 4; 39 | while(should_fill_at(pixel_pos)){ 40 | y--; 41 | pixel_pos = (y*c_width + x) * 4; 42 | } 43 | reach_left = false; 44 | reach_right = false; 45 | // eslint-disable-next-line no-constant-condition 46 | while(true){ 47 | y++; 48 | pixel_pos = (y*c_width + x) * 4; 49 | 50 | if(!(y < c_height && should_fill_at(pixel_pos))){ 51 | break; 52 | } 53 | 54 | do_fill_at(pixel_pos); 55 | 56 | if(x > 0){ 57 | if(should_fill_at(pixel_pos - 4)){ 58 | if(!reach_left){ 59 | stack.push([x - 1, y]); 60 | reach_left = true; 61 | } 62 | }else if(reach_left){ 63 | reach_left = false; 64 | } 65 | } 66 | 67 | if(x < c_width-1){ 68 | if(should_fill_at(pixel_pos + 4)){ 69 | if(!reach_right){ 70 | stack.push([x + 1, y]); 71 | reach_right = true; 72 | } 73 | }else if(reach_right){ 74 | reach_right = false; 75 | } 76 | } 77 | 78 | pixel_pos += c_width * 4; 79 | } 80 | } 81 | 82 | // rerender the texture with the new pixels 83 | const newTexture = PIXI.Texture.fromBuffer(id, c_width, c_height); 84 | const newTextureSprite = new PIXI.Sprite(newTexture); 85 | renderer.render(newTextureSprite, renderTexture, true, null, false); 86 | 87 | function should_fill_at(pixel_pos){ 88 | return ( 89 | // matches start color (i.e. region to fill) 90 | id[pixel_pos+0] === start_r && 91 | id[pixel_pos+1] === start_g && 92 | id[pixel_pos+2] === start_b && 93 | id[pixel_pos+3] === start_a 94 | ); 95 | } 96 | 97 | function do_fill_at(pixel_pos){ 98 | id[pixel_pos+0] = fill_r; 99 | id[pixel_pos+1] = fill_g; 100 | id[pixel_pos+2] = fill_b; 101 | id[pixel_pos+3] = fill_a; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/view/header/resize_modal.js: -------------------------------------------------------------------------------- 1 | import { html, useState, useEffect } from '../../vendor/preact.js'; 2 | import { Modal } from '../components/modal.js'; 3 | import storeSingleton from '../../foundation/store.js'; 4 | 5 | export const ResizeModal = (props) => { 6 | 7 | const [w, setWidth] = useState(storeSingleton.get('dimensions.width')); 8 | const [h, setHeight] = useState(storeSingleton.get('dimensions.height')); 9 | 10 | useEffect(() => { 11 | storeSingleton.subscribe('dimensions', (dim) => { 12 | setWidth(dim.width) 13 | setHeight(dim.height) 14 | }) 15 | }, []) 16 | 17 | const onclick = () => { 18 | const dimensions = { 19 | width: document.getElementById('resize_w').value || 800, 20 | height: document.getElementById('resize_h').value || 600 21 | } 22 | storeSingleton.publish('resize', dimensions); 23 | 24 | storeSingleton.publish('modal_hide') 25 | storeSingleton.publish('center') 26 | } 27 | 28 | return (html`<${Modal} id="resize"> 29 |

Resize Canvas

30 |
31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |

All resizes assume that the origin / scale point is at the top left corner of the image. Sorry, there's no way to change that right now.

40 |
41 |
42 | 43 | 46 |
47 | `) 48 | } 49 | 50 | 51 | export const StretchModal = (props) => { 52 | 53 | const [w, setWidth] = useState(storeSingleton.get('dimensions.width')); 54 | const [h, setHeight] = useState(storeSingleton.get('dimensions.height')); 55 | 56 | useEffect(() => { 57 | storeSingleton.subscribe('dimensions', (dim) => { 58 | setWidth(dim.width) 59 | setHeight(dim.height) 60 | }) 61 | }, []) 62 | 63 | const onclick = () => { 64 | const dimensions = { 65 | width: document.getElementById('stretch_w').value || 800, 66 | height: document.getElementById('stretch_h').value || 600 67 | } 68 | storeSingleton.publish('stretch', dimensions); 69 | 70 | storeSingleton.publish('modal_hide') 71 | storeSingleton.publish('center') 72 | } 73 | 74 | return (html`<${Modal} id="stretch"> 75 |

Stretch Canvas

76 |
77 |
78 | 79 | 80 |
81 |
82 | 83 | 84 |
85 |
86 |
87 | 88 | 91 |
92 | `) 93 | } 94 | -------------------------------------------------------------------------------- /src/view/palette-pixi.js: -------------------------------------------------------------------------------- 1 | import { colors } from '../palette/raw_colors.js' 2 | import storeSingleton from '../foundation/store.js'; 3 | 4 | class Swatch { 5 | constructor(app, shader, index, color, width, height) { 6 | this.app = app; 7 | this.isSelected = undefined; 8 | 9 | this.swatchWidth = width; 10 | this.swatchHeight = height; 11 | this.color = color; 12 | this.swatch = new PIXI.Graphics(); 13 | this.swatch.filters = [shader] 14 | 15 | this.updateSelected(this.color === storeSingleton.get('color')); 16 | 17 | this.swatch.position.x = index * this.swatchWidth + 1; 18 | this.swatch.interactive = true; 19 | this.swatch.on('pointerdown', () => { 20 | storeSingleton.update('color', this.color) 21 | }) 22 | this._addSubscriptions(); 23 | } 24 | 25 | _addSubscriptions() { 26 | storeSingleton.subscribe('color', (newColor) => { 27 | this.updateSelected(newColor === this.color) 28 | }); 29 | } 30 | 31 | updateSelected(newIsSelected) { 32 | if (this.isSelected === newIsSelected) { 33 | return; 34 | } 35 | 36 | this.isSelected = newIsSelected; 37 | 38 | if (this.isSelected) { 39 | this.drawSelected(); 40 | } else { 41 | this.drawUnselected(); 42 | } 43 | this.app.ticker.update(); 44 | } 45 | 46 | getGraphic() { 47 | return this.swatch; 48 | } 49 | 50 | drawUnselected() { 51 | this.swatch.clear(); 52 | this.swatch.beginFill(this.color); 53 | this.swatch.lineStyle(1, 0xffffff); 54 | this.swatch.drawRect(0, 0, this.swatchWidth, this.swatchHeight); 55 | this.swatch.endFill(); 56 | } 57 | 58 | drawSelected() { 59 | this.swatch.clear(); 60 | this.swatch.beginFill(this.color); 61 | this.swatch.lineStyle(1, 0xffffff); 62 | this.swatch.drawRect(0, 0, this.swatchWidth, this.swatchHeight+8); 63 | this.swatch.endFill(); 64 | } 65 | } 66 | 67 | class Palette { 68 | constructor(element) { 69 | this.swatchWidth = 20; 70 | this.swatchHeight = 32; 71 | this.selectedHeight = 40; 72 | this.app = new PIXI.Application({ 73 | width: colors.length * this.swatchWidth + 2, 74 | height: this.selectedHeight, 75 | backgroundColor: 0x000000, 76 | autoStart: false 77 | }); 78 | 79 | this.app.ticker.update(); 80 | 81 | element.appendChild(this.app.view); 82 | 83 | storeSingleton.subscribe('resources', (res) => { 84 | this._onLoadResources(res); 85 | }) 86 | } 87 | 88 | createSwatch(index, color) { 89 | const swatch = new Swatch(this.app, this.shader, index, color, this.swatchWidth, this.swatchHeight); 90 | this.app.stage.addChild(swatch.getGraphic()) 91 | this.app.ticker.update(); 92 | } 93 | 94 | _onLoadResources(res) { 95 | this.shader = res.shader; 96 | for (let i = 0; i < colors.length; i++ ) { 97 | this.createSwatch(i, colors[i]) 98 | } 99 | } 100 | } 101 | 102 | export default Palette; -------------------------------------------------------------------------------- /src/view/layout.js: -------------------------------------------------------------------------------- 1 | import { html } from '../vendor/preact.js'; 2 | import store from '../foundation/store.js'; 3 | 4 | import { ToolButton, IconButton } from './components/tool_button.js' 5 | import { DropdownButton, DropdownItem } from './components/dropdown.js'; 6 | import { toolId } from '../frame/tools.js' 7 | 8 | import BrushShapes from './header/brush_shapes.js' 9 | import Toolbox from './header/toolbox.js' 10 | import { ResizeModal, StretchModal } from './header/resize_modal.js' 11 | import UndoRedo from './sidebar/undo_redo.js' 12 | import BrushOptions from './sidebar/brush_options.js' 13 | import AboutModal from './about_modal.js'; 14 | import SettingsModal from './settings_modal.js'; 15 | 16 | const Layout = (props) => { 17 | return (html` 18 |
19 |
66 | `); 67 | } 68 | 69 | export default Layout; -------------------------------------------------------------------------------- /src/frame/autosave.js: -------------------------------------------------------------------------------- 1 | import storeSingleton from "../foundation/store.js"; 2 | 3 | class Autosave { 4 | constructor(artwork) { 5 | this.settingName = 'setting_autosave_enabled'; 6 | this.canUseLocalStorage = false; 7 | this._initializeSetting(); 8 | 9 | this.artwork = artwork; 10 | this.filename = 'autosave'; 11 | this.enabled = storeSingleton.get('autosave_enabled'); 12 | 13 | console.log('does this browser support autosave? ', this.canUseLocalStorage); 14 | storeSingleton.update('can_autosave', this.canUseLocalStorage); 15 | 16 | if (this.canUseLocalStorage) { 17 | console.log('is autosave enabled? ', this.enabled); 18 | storeSingleton.subscribe('autosave_enabled', (val) => { 19 | this.enabled = val; 20 | window.localStorage.setItem(this.settingName, val ? 'true' : 'false') 21 | if (!this.enabled) { 22 | console.log('autosaves disabled; clearing current autosave.') 23 | this.clear(); 24 | } else { 25 | console.log('enabling autosaves. creating an autosave now...') 26 | this.save(); 27 | } 28 | }); 29 | } 30 | 31 | const self = this; 32 | 33 | window.addEventListener('beforeunload', (e) => { 34 | const isDirty = storeSingleton.get('dirty'); 35 | if (!isDirty) { 36 | return; 37 | } 38 | 39 | // if we have autosave enabled, we don't really have to worry about this 40 | if (self.isEnabled()) { 41 | return; 42 | } 43 | 44 | const confirmExit = 'Exit without saving?'; 45 | (e || window.event).returnValue = confirmExit; 46 | return confirmExit; 47 | }); 48 | } 49 | 50 | _initializeSetting() { 51 | 52 | try { 53 | // load settings 54 | let autosaveSetting = window.localStorage.getItem(this.settingName); 55 | if (autosaveSetting === null) { 56 | autosaveSetting = 'true'; // true is default 57 | } 58 | 59 | storeSingleton.update('autosave_enabled', autosaveSetting === 'true' ? true : false); 60 | this.canUseLocalStorage = true; 61 | } catch (e) { 62 | console.log('autosave disabled because we don\'t have access to localStorage'); 63 | storeSingleton.update('autosave_enabled', false); 64 | this.canUseLocalStorage = false; 65 | } 66 | } 67 | 68 | isEnabled() { 69 | return this.canUseLocalStorage && this.enabled; 70 | } 71 | 72 | clear() { 73 | if (!this.isEnabled()) { 74 | return; 75 | } 76 | window.localStorage.removeItem(this.filename); 77 | } 78 | 79 | load() { 80 | if (!this.isEnabled()) { 81 | return undefined; 82 | } 83 | try { 84 | const data = window.localStorage.getItem(this.filename); 85 | if (!data || !data.length) { 86 | return undefined; 87 | } 88 | 89 | return data; 90 | } catch (e) { 91 | console.log(e); 92 | return undefined; 93 | } 94 | } 95 | 96 | save() { 97 | if (!this.isEnabled()) { 98 | return; 99 | } 100 | // oh well, this is kind of spaghetti-ing a bit 101 | const canvas = this.artwork.getCanvas(); 102 | const data = canvas.toDataURL(); 103 | try { 104 | window.localStorage.setItem(this.filename, data); 105 | } catch (e) { 106 | console.log(e); 107 | } 108 | } 109 | } 110 | 111 | export default Autosave; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Strike 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/tools/paint.js: -------------------------------------------------------------------------------- 1 | import Tool from './base.js'; 2 | import { point_direction } from '../utils/point_direction.js'; 3 | 4 | import storeSingleton from "../foundation/store.js"; 5 | import { toolId } from '../frame/tools.js'; 6 | 7 | class Paint extends Tool { 8 | constructor() { 9 | super(); 10 | this.previousPoint = null; 11 | this.brushSize = 10; 12 | this.brushShape = "flat"; // square or flat or round 13 | this.color = 0xffffff; 14 | 15 | // special brush params 16 | this.shouldRotateBrush = true; 17 | 18 | // TODO: implement 19 | this.shouldUsePenPressure = true; 20 | 21 | // prepare circle texture, that will be our brush 22 | this.brush = new PIXI.Graphics(); 23 | this.lineBrush = new PIXI.Graphics(); 24 | this.lineStyle = {}; 25 | this.toolType = toolId.PAINT; 26 | 27 | this.subscribe(); 28 | this.updateBrush(); 29 | 30 | this.stroke = []; 31 | } 32 | 33 | subscribe() { 34 | this.brushSize = storeSingleton.get('paint.size') || 1; 35 | this.brushShape = storeSingleton.get('paint.shape') || 'flat'; 36 | storeSingleton.subscribe('paint.size', (newSize) => { 37 | this.updateBrush({ 38 | size: newSize 39 | }); 40 | }) 41 | 42 | storeSingleton.subscribe('paint.shape', (newShape) => { 43 | this.updateBrush({ 44 | shape: newShape 45 | }); 46 | }) 47 | 48 | storeSingleton.subscribe('color', (color) => { 49 | this.updateBrush({ color }); 50 | }) 51 | 52 | storeSingleton.update('paint.shape', this.brushShape); 53 | } 54 | 55 | updateBrush({size, shape, color} = {}) { 56 | this.brushSize = size || this.brushSize; 57 | this.brushShape = shape || this.brushShape; 58 | if (color !== undefined && color !== null) { 59 | this.color = color; 60 | } 61 | 62 | this.brush.angle = 0; 63 | 64 | this._updateBrushTemporary(this.brushSize, this.brushShape, this.color); 65 | } 66 | 67 | _updateBrushTemporary(size, shape, color) { 68 | this.brush.clear(); 69 | this.brush.beginFill(color); 70 | if (size === 1) { 71 | this.brush.drawRect(0, 0, 1, 1); 72 | } else if (size > 1 && shape === "round") { 73 | this.brush.drawCircle(0, 0, size / 2); 74 | this.shouldRotateBrush = false; 75 | } else if (size > 1 && shape === "flat") { 76 | this.brush.drawRect(-Math.ceil(size/4), -Math.ceil(size/2), size/2, size); 77 | this.shouldRotateBrush = true; 78 | } else { 79 | this.brush.drawRect(-Math.ceil(size/2), -Math.ceil(size/2), size, size); 80 | this.shouldRotateBrush = false; 81 | } 82 | this.brush.endFill(); 83 | this.lineStyle = { 84 | width: size, 85 | color: color, 86 | native: false, 87 | }; 88 | } 89 | 90 | begin(renderer, renderTexture, event, artwork) { 91 | this.stroke = [] 92 | this.move(renderer, renderTexture, event, artwork); 93 | } 94 | 95 | move(renderer, renderTexture, event, artwork) { 96 | const viewport = artwork.getViewport(); 97 | 98 | let newPoint = new PIXI.Point(this.getX(event, viewport), this.getY(event, viewport)); 99 | if (this.previousPoint && Math.abs(this.previousPoint.x - newPoint.x) < 0.01 && Math.abs(this.previousPoint.y - newPoint.y) < 0.01) { 100 | return; // abort early, nothing moved. 101 | } 102 | 103 | if (this.previousPoint) { 104 | if (this.shouldRotateBrush && this.brushSize > 1) { 105 | this.brush.angle = point_direction(this.previousPoint.x, this.previousPoint.y, newPoint.x, newPoint.y); 106 | } 107 | this._strokeLine(renderer, renderTexture, newPoint, this.previousPoint); 108 | } 109 | 110 | this._strokePoint(renderer, renderTexture, newPoint.x, newPoint.y); 111 | 112 | this.stroke.push({ 113 | x: newPoint.x, 114 | y: newPoint.y, 115 | size: this.brushSize, 116 | angle: this.brush.angle, 117 | shape: this.brushShape, 118 | color: this.color, 119 | }); 120 | 121 | this.previousPoint = newPoint; 122 | } 123 | 124 | _strokePoint(renderer, renderTexture, x, y) { 125 | this.brush.position.x = x; 126 | this.brush.position.y = y; 127 | 128 | renderer.render(this.brush, renderTexture, false, null, false); 129 | } 130 | 131 | _strokeLine(renderer, renderTexture, newPoint, previousPoint) { 132 | this.lineBrush.clear(); 133 | this.lineBrush.lineStyle(this.lineStyle); 134 | this.lineBrush.moveTo(previousPoint.x + 0.5, previousPoint.y + 0.5) 135 | this.lineBrush.lineTo(newPoint.x + 0.5, newPoint.y + 0.5) 136 | 137 | renderer.render(this.lineBrush, renderTexture, false, null, false); 138 | } 139 | 140 | end(renderer, renderTexture, event, artwork) { 141 | const viewport = artwork.getViewport(); 142 | 143 | let x = this.getX(event, viewport); 144 | let y = this.getY(event, viewport); 145 | 146 | this.stroke.push({ 147 | x: x, 148 | y: y, 149 | size: this.brushSize, 150 | angle: this.brush.angle, 151 | shape: this.brushShape, 152 | color: this.color, 153 | }); 154 | 155 | this._strokePoint(renderer, renderTexture, x, y); 156 | this.previousPoint = null; 157 | 158 | artwork.addUndoable(this.toolType, this.stroke); 159 | } 160 | 161 | applyOperation(operation, renderer, renderTexture) { 162 | let prev = undefined; 163 | 164 | // should be a stroke object 165 | for (let point of operation) { 166 | this._updateBrushTemporary(point.size, point.shape, point.color); 167 | if (prev) { 168 | this.brush.angle = point.angle; 169 | this._strokeLine(renderer, renderTexture, point, prev); 170 | } 171 | this._strokePoint(renderer, renderTexture, point.x, point.y); 172 | 173 | prev = point; 174 | } 175 | this.updateBrush(); 176 | } 177 | } 178 | 179 | export default Paint; -------------------------------------------------------------------------------- /styles/style.css: -------------------------------------------------------------------------------- 1 | /* override browser default */ 2 | html, 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | 7 | font-size: 20px; 8 | } 9 | 10 | button { 11 | font-family: monospace; 12 | font-size: 20px; 13 | background-color: #000; 14 | color: #FFF; 15 | border: 1px solid #FFF; 16 | } 17 | 18 | button:hover { 19 | background-color: #FFF; 20 | color: #000; 21 | } 22 | 23 | /* use viewport-relative units to cover page fully */ 24 | body { 25 | height: 100vh; 26 | width: 100vw; 27 | overflow: hidden; 28 | } 29 | 30 | /* include border and padding in element width and height */ 31 | * { 32 | box-sizing: border-box; 33 | } 34 | 35 | a { 36 | color: #FFF; 37 | } 38 | 39 | #ui { 40 | height: 100vh; 41 | width: 100vw; 42 | 43 | max-width: 100vw; 44 | max-height: 100vh; 45 | 46 | overflow: hidden; 47 | } 48 | 49 | #app { 50 | width: 100vw; 51 | height: 100vh; 52 | display: grid; 53 | grid-template: 'header header' auto 54 | 'sidebar main' 1fr / 55 | auto 1fr; 56 | } 57 | 58 | #header { 59 | max-width: 100vw; 60 | grid-area: header; 61 | background-color: #000; 62 | border-bottom: 1px solid #FFF; 63 | flex-wrap: wrap; 64 | display: flex; 65 | align-items: center; 66 | } 67 | 68 | .header-right { 69 | display: flex; 70 | flex-wrap: wrap; 71 | align-items: center; 72 | margin-left: auto; 73 | } 74 | 75 | #sidebar { 76 | max-height: calc(100vh - 40px); 77 | grid-area: sidebar; 78 | background-color: #000; 79 | border-right: 1px solid #FFF; 80 | display: flex; 81 | flex-direction: column; 82 | flex-wrap: wrap; 83 | align-items: center; 84 | justify-content: center; 85 | } 86 | 87 | .sidebar-top { 88 | margin-bottom: auto; 89 | } 90 | 91 | .sidebar-middle { 92 | display: flex; 93 | flex-wrap: wrap; 94 | flex-flow: column wrap; 95 | flex-direction: column; 96 | align-items: center; 97 | justify-content: center; 98 | } 99 | 100 | .sidebar-bottom { 101 | margin-top: auto; 102 | } 103 | 104 | #main { 105 | grid-area: main; 106 | } 107 | 108 | #toolbox { 109 | display: flex; 110 | } 111 | 112 | #shapes { 113 | display: flex; 114 | } 115 | 116 | #palette { 117 | margin-left: 20px; 118 | } 119 | 120 | .tool-button { 121 | padding: 10px; 122 | border: 1px solid rgba(0,0,0,0); 123 | background-color: #FFF; 124 | } 125 | 126 | .tool-button i { 127 | width: 20px; 128 | height: 20px; 129 | color: #000; 130 | } 131 | 132 | .tool-button.active, 133 | .tool-button:hover, 134 | .tool-button:active { 135 | background-color: #000; 136 | border: 1px solid #FFF; 137 | } 138 | 139 | .tool-button.active i, 140 | .tool-button:hover i, 141 | .tool-button:active i { 142 | color: #FFF; 143 | } 144 | 145 | .tool-button.disabled { 146 | background-color: #FFF!important; 147 | } 148 | 149 | .tool-button.disabled i { 150 | color: #ccc!important; 151 | } 152 | 153 | .vertical-slider { 154 | height: 180px; 155 | width: 40px; 156 | display: flex; 157 | flex-direction: column; 158 | justify-content: center; 159 | align-items: center; 160 | } 161 | 162 | input[type="range"].range 163 | { 164 | cursor: pointer; 165 | width: 160px !important; 166 | height: 24px; 167 | -webkit-appearance: none; 168 | z-index: 2; 169 | border: 1px solid #fff; 170 | background-color: #000; 171 | } 172 | 173 | /*customised range slider icon*/ input[type="range"].range::-webkit-slider-thumb 174 | { 175 | -webkit-appearance: none; 176 | width: 24px; 177 | height: 24px; 178 | background-color: #FFF; 179 | } 180 | 181 | /* set range from 1 - 0 vertically (highest on top) */ .vertical 182 | { 183 | -webkit-transform:rotate(270deg); 184 | -moz-transform:rotate(270deg); 185 | -o-transform:rotate(270deg); 186 | -ms-transform:rotate(270deg); 187 | transform:rotate(270deg); 188 | } 189 | 190 | #brush-options { 191 | display: flex; 192 | flex-direction: column; 193 | padding: 20px 0; 194 | } 195 | 196 | label { 197 | font-family: monospace; 198 | color: #FFF; 199 | text-align: center; 200 | } 201 | 202 | .dropdown { 203 | overflow: visible; 204 | z-index: 10; 205 | position: relative; 206 | } 207 | 208 | .dropdown-button { 209 | padding: 8px 12px; 210 | } 211 | 212 | .dropdown-list { 213 | position: absolute; 214 | z-index: 10; 215 | color: #FFF; 216 | background-color: #000; 217 | border: 1px solid #FFF; 218 | min-width: 200px; 219 | padding: 10px; 220 | } 221 | 222 | .dropdown-list ul { 223 | margin: 0; 224 | padding: 0; 225 | } 226 | 227 | .dropdown-item { 228 | width: 100%; 229 | } 230 | 231 | .dropdown-item-button { 232 | width: 100%; 233 | background-color: transparent; 234 | color: #FFF; 235 | border: none; 236 | } 237 | 238 | .dropdown-item-button:hover { 239 | background-color: #FFF; 240 | color: #000; 241 | } 242 | 243 | .modal-overlay { 244 | position: absolute; 245 | top: 0; 246 | left: 0; 247 | right: 0; 248 | bottom: 0; 249 | background-color: rgba(0,0,0,0.5); 250 | z-index: 100; 251 | } 252 | 253 | .modal { 254 | position: absolute; 255 | z-index: 200; 256 | padding: 40px; 257 | font-family: monospace; 258 | border: 1px solid #FFF; 259 | background-color: #000; 260 | color: #FFF; 261 | top: 50%; 262 | left: 50%; 263 | transform: translate(-50%, -50%); 264 | } 265 | 266 | .modal div { 267 | margin-bottom: 12px; 268 | } 269 | 270 | .modal button { 271 | padding: 8px; 272 | } 273 | 274 | input[type=number] { 275 | background-color: #000; 276 | padding: 4px; 277 | font-size: 18px; 278 | color: #FFF; 279 | border: 1px solid #FFF; 280 | box-shadow: none; 281 | font-family: monospace; 282 | } 283 | 284 | .h-gap { 285 | width: 20px; 286 | } 287 | 288 | .modal h1 { 289 | margin-bottom: 1em; 290 | font-size: 24px; 291 | } 292 | 293 | .modal p { 294 | margin-bottom: 0.5em; 295 | } 296 | 297 | .modal ul { 298 | list-style: disc; 299 | padding-left: 40px; 300 | } 301 | 302 | .modal li { 303 | margin-bottom: 1em; 304 | } 305 | 306 | .setting { 307 | margin-bottom: 1em; 308 | } 309 | 310 | .setting-label { 311 | font-size: 24px; 312 | } 313 | 314 | blockquote { 315 | padding-left: 24px; 316 | border-left: 2px solid #FFF; 317 | margin: 1em 0; 318 | } 319 | 320 | canvas { 321 | image-rendering: optimizeSpeed; 322 | } -------------------------------------------------------------------------------- /src/frame/undo_stack.js: -------------------------------------------------------------------------------- 1 | import store from '../foundation/store.js'; 2 | 3 | // TODO: undo / redo with the snapshot switchover is still questionable. 4 | 5 | class UndoStack { 6 | constructor(artwork) { 7 | this.operationsPerSnapshot = 3; 8 | this.maxSnapshots = 15; 9 | 10 | /* 11 | each item in the snapshotStack is an obj of the form 12 | { 13 | texture: texture, 14 | index: number 15 | } 16 | where the index is the last index before the snapshot. So the snapshot with index -1 was created before any operations happened. 17 | */ 18 | this.snapshotStack = []; 19 | this.operationStack = []; 20 | this.currentOp = -1; // the last operation that is currently active on the canvas. Usually the last item in the operation stack. 21 | 22 | store.update('can_undo', this.canUndo()); 23 | store.update('can_redo', this.canRedo()); 24 | 25 | this.artwork = artwork; 26 | 27 | // listen to messages 28 | store.listen('undo', () => { 29 | this.undo(1); 30 | store.update('can_undo', this.canUndo()); 31 | store.update('can_redo', this.canRedo()); 32 | store.publish('on_undo_complete'); 33 | 34 | }); 35 | store.listen('redo', () => { 36 | this.redo(1); 37 | store.update('can_undo', this.canUndo()); 38 | store.update('can_redo', this.canRedo()); 39 | store.publish('on_redo_complete'); 40 | }) 41 | } 42 | 43 | reset() { 44 | this.snapshotStack = []; 45 | this.operationStack = []; 46 | this.currentOp = -1; 47 | 48 | this._addSnapshot(); 49 | 50 | store.update('can_undo', this.canUndo()); 51 | store.update('can_redo', this.canRedo()); 52 | } 53 | 54 | _pruneSnapshots() { 55 | if (this.snapshotStack.length && this.currentOp < this.operationStack.length - 1) { 56 | // remove everything on the redo stack. This means clobbering any snapshots whose 57 | // indexes are past the currentOp index. 58 | let cutOffpoint = this.snapshotStack.length; 59 | for (let i = 0; i < this.snapshotStack.length; i++) { 60 | const snapshot = this.snapshotStack[this.snapshotStack.length - 1 - i]; 61 | if (snapshot.index <= this.currentOp) { 62 | break; 63 | } 64 | cutOffpoint = this.snapshotStack.length - 1 - i; 65 | } 66 | 67 | this.snapshotStack = this.snapshotStack.slice(0, cutOffpoint); 68 | } 69 | } 70 | 71 | _freeOldUndos() { 72 | // gets rid of really old snapshots. 73 | if (this.snapshotStack.length <= this.maxSnapshots) { 74 | return; // don't do anything 75 | } 76 | 77 | // start pruning old snapshots 78 | const sliceStart = this.snapshotStack.length - this.maxSnapshots; 79 | const slicedSnapshots = this.snapshotStack.slice(sliceStart); 80 | 81 | const operationSliceStart = slicedSnapshots[0].index + 1; // min 0 82 | const slicedOperations = this.operationStack.slice(operationSliceStart); 83 | 84 | for (let snapshot of slicedSnapshots) { 85 | snapshot.index -= operationSliceStart; 86 | } 87 | 88 | this.snapshotStack = slicedSnapshots; 89 | this.operationStack = slicedOperations; 90 | this.currentOp -= operationSliceStart; 91 | } 92 | 93 | _addSnapshot() { 94 | if (!this.artwork.renderTexture) { 95 | return; 96 | } 97 | 98 | const snapshotTexture = PIXI.RenderTexture.create(this.artwork.renderTexture.width, this.artwork.renderTexture.height); 99 | const existingSprite = new PIXI.Sprite(this.artwork.renderTexture); 100 | this.artwork.app.renderer.render(existingSprite, snapshotTexture, true, null, false); 101 | this.snapshotStack.push({ 102 | // TODO: we need to do a better job of this. 103 | texture: snapshotTexture, 104 | index: this.currentOp, 105 | }); 106 | } 107 | 108 | addUndoable(toolName, operation, createSnapshot) { 109 | 110 | const lastOperationWithSnapshot = this.snapshotStack[this.snapshotStack.length - 1].index; 111 | if (lastOperationWithSnapshot + this.operationsPerSnapshot < this.currentOp) { 112 | createSnapshot = true; 113 | } 114 | 115 | this._pruneSnapshots(); 116 | 117 | // clobber the redo stack. 118 | this.operationStack = this.operationStack.slice(0, this.currentOp + 1); 119 | 120 | this.operationStack.push({ 121 | toolName, 122 | operation 123 | }); 124 | 125 | this.currentOp = this.operationStack.length - 1; 126 | 127 | if (createSnapshot) { 128 | this._addSnapshot(); 129 | } 130 | 131 | this._freeOldUndos(); 132 | 133 | store.update('can_undo', this.canUndo()); 134 | store.update('can_redo', this.canRedo()); 135 | 136 | } 137 | 138 | undo(steps) { 139 | steps = steps || 1; 140 | if (!this.canUndo()) { 141 | // nothing to undo. 142 | return; 143 | } 144 | 145 | let targetOp = this.currentOp - steps; 146 | if (targetOp < -1) { 147 | targetOp = -1; 148 | } 149 | let targetSnapshot = this._getClosestSnapshot(targetOp); 150 | 151 | // reset artwork buffer to snapshot 152 | this.artwork.setToSnapshot(targetSnapshot.texture); 153 | 154 | // apply operations on the snapshot until you get to the targetOp. 155 | for (let op = targetSnapshot.index + 1; op <= targetOp; op++) { 156 | this.artwork.applySavedOperation(this.operationStack[op]); 157 | } 158 | 159 | this.currentOp = targetOp; 160 | } 161 | 162 | canUndo() { 163 | return this.currentOp >= 0; 164 | } 165 | 166 | canRedo() { 167 | return this.currentOp < this.operationStack.length - 1; 168 | } 169 | 170 | redo(steps) { 171 | steps = steps || 1; 172 | 173 | if (!this.canRedo()) { 174 | return; 175 | } 176 | 177 | let targetOp = this.currentOp + steps; 178 | if (targetOp > this.operationStack.length - 1) { 179 | targetOp = this.operationStack.length - 1; 180 | } 181 | 182 | let targetSnapshot = this._getClosestSnapshot(targetOp); 183 | 184 | // reset artwork buffer to snapshot 185 | this.artwork.setToSnapshot(targetSnapshot.texture); 186 | 187 | for (let op = targetSnapshot.index + 1; op <= targetOp; op ++) { 188 | this.artwork.applySavedOperation(this.operationStack[op]); 189 | } 190 | 191 | this.currentOp = targetOp; 192 | } 193 | 194 | _getClosestSnapshot(targetIndex) { 195 | // get the snapshot closest to currentOp 196 | for (let i = 0; i < this.snapshotStack.length; i++) { 197 | const snapshot = this.snapshotStack[this.snapshotStack.length - 1 - i]; 198 | 199 | if (snapshot.index <= targetIndex) { 200 | return snapshot; 201 | } 202 | } 203 | } 204 | } 205 | 206 | export default UndoStack; -------------------------------------------------------------------------------- /src/vendor/preact.js: -------------------------------------------------------------------------------- 1 | var e,n,t,_,o,r,u,l={},i=[],c=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord/i;function s(e,n){for(var t in n)e[t]=n[t];return e}function f(e){var n=e.parentNode;n&&n.removeChild(e)}function a(e,n,t){var _,o=arguments,r={};for(_ in n)"key"!==_&&"ref"!==_&&(r[_]=n[_]);if(arguments.length>3)for(t=[t],_=3;_=t.__.length&&t.__.push({}),t.__[n]}function q(e){return B(te,e)}function B(e,n,t){var _=O(A++);return _.__c||(_.__c=M,_.__=[t?t(n):te(void 0,n),function(n){var t=e(_.__[0],n);_.__[0]!==t&&(_.__[0]=t,_.__c.setState({}))}]),_.__}function $(e,n){var t=O(A++);ne(t.__H,n)&&(t.__=e,t.__H=n,M.__H.__h.push(t))}function j(e,n){var t=O(A++);ne(t.__H,n)&&(t.__=e,t.__H=n,M.__h.push(t))}function z(e){return J(function(){return{current:e}},[])}function G(e,n,t){j(function(){"function"==typeof e?e(n()):e&&(e.current=n())},null==t?t:t.concat(e))}function J(e,n){var t=O(A++);return ne(t.__H,n)?(t.__H=n,t.__h=e,t.__=e()):t.__}function K(e,n){return J(function(){return e},n)}function Q(e){var n=M.context[e.__c];if(!n)return e.__;var t=O(A++);return null==t.__&&(t.__=!0,n.sub(M)),n.props.value}function X(n,t){e.useDebugValue&&e.useDebugValue(t?t(n):n)}function Y(){L.some(function(n){if(n.__P)try{n.__H.__h.forEach(Z),n.__H.__h.forEach(ee),n.__H.__h=[]}catch(t){return n.__H.__h=[],e.__e(t,n.__v),!0}}),L=[]}function Z(e){e.t&&e.t()}function ee(e){var n=e.__();"function"==typeof n&&(e.t=n)}function ne(e,n){return!e||n.some(function(n,t){return n!==e[t]})}function te(e,n){return"function"==typeof n?n(e):n}e.__r=function(e){W&&W(e),A=0,(M=e.__c).__H&&(M.__H.__h.forEach(Z),M.__H.__h.forEach(ee),M.__H.__h=[])},e.diffed=function(n){R&&R(n);var t=n.__c;if(t){var _=t.__H;_&&_.__h.length&&(1!==L.push(t)&&F===e.requestAnimationFrame||((F=e.requestAnimationFrame)||function(e){var n,t=function(){clearTimeout(_),cancelAnimationFrame(n),setTimeout(e)},_=setTimeout(t,100);"undefined"!=typeof window&&(n=requestAnimationFrame(t))})(Y))}},e.__c=function(n,t){t.some(function(n){try{n.__h.forEach(Z),n.__h=n.__h.filter(function(e){return!e.__||ee(e)})}catch(_){t.some(function(e){e.__h&&(e.__h=[])}),t=[],e.__e(_,n.__v)}}),V&&V(n,t)},e.unmount=function(n){I&&I(n);var t=n.__c;if(t){var _=t.__H;if(_)try{_.__.forEach(function(e){return e.t&&e.t()})}catch(n){e.__e(n,t.__v)}}};var _e=function(e,n,t,_){var o;n[0]=0;for(var r=1;r=5&&((o||!e&&5===_)&&(u.push(_,0,o,t),_=6),e&&(u.push(_,e,0,t),_=6)),o=""},i=0;i"===n?(_=1,o=""):o=n+o[0]:r?n===r?r="":o+=n:'"'===n||"'"===n?r=n:">"===n?(l(),_=1):_&&("="===n?(_=5,t=o,o=""):"/"===n&&(_<5||">"===e[i][c+1])?(l(),3===_&&(u=u[0]),_=u,(u=u[0]).push(2,0,_),_=0):" "===n||"\t"===n||"\n"===n||"\r"===n?(l(),_=2):o+=n),3===_&&"!--"===o&&(_=4,u=u[0])}return l(),u}(e)),n),arguments,[])).length>1?n:n[0]}.bind(a);export{a as h,re as html,T as render,d as Component,U as createContext,q as useState,B as useReducer,$ as useEffect,j as useLayoutEffect,z as useRef,G as useImperativeHandle,J as useMemo,K as useCallback,Q as useContext,X as useDebugValue}; 2 | -------------------------------------------------------------------------------- /src/frame/artwork.js: -------------------------------------------------------------------------------- 1 | import store from '../foundation/store.js'; 2 | import tools, { toolId } from './tools.js'; 3 | import { rgbQuantColors } from '../palette/raw_colors.js'; 4 | 5 | import UndoStack from './undo_stack.js'; 6 | import Autosave from './autosave.js'; 7 | 8 | let EMPTY_SPRITE = new PIXI.Sprite(PIXI.Texture.EMPTY); 9 | 10 | class Artwork { 11 | 12 | constructor(element) { 13 | store.update('dirty', false); 14 | 15 | this.undo = new UndoStack(this); 16 | this.autosave = new Autosave(this); 17 | 18 | this.app = new PIXI.Application({ 19 | width: element.offsetWidth, 20 | height: element.offsetHeight, 21 | backgroundColor: 0x1b1b1b, 22 | 23 | // this ensures that we don't redraw the canvas every frame. 24 | // however, this also means that we need to call this.app.ticker.update() 25 | // every time the view changes 26 | // (e.g. when we paint on the canvas, or when we move the view) 27 | // so that the app knows to redraw it. The `render` function is provided to render 28 | // something onto the canvas *and* update the ticker immediately afterwards. 29 | autoStart: false 30 | }); 31 | 32 | this.app.resizeTo = element; 33 | 34 | // create viewport 35 | this.viewport = new Viewport.Viewport({ 36 | interaction: this.app.renderer.plugins.interaction // the interaction module is important for wheel to work properly when renderer.view is placed or scaled 37 | }) 38 | 39 | // add the viewport to the stage 40 | this.app.stage.addChild(this.viewport) 41 | this.viewport.wheel().decelerate(); 42 | 43 | // we need to redraw the surface when the viewport moves so that 44 | // we see the canvas's new position 45 | this.viewport.on('moved', () => { 46 | this.app.ticker.update(); 47 | }) 48 | 49 | this.renderTexture = undefined; 50 | this.renderTextureSprite = undefined; 51 | 52 | this.dragging = false; 53 | this.currentTool = tools.get(store.get('tool')); 54 | 55 | store.subscribe('tool', (newTool) => { 56 | this.currentTool = tools.get(newTool); 57 | if (newTool === toolId.MOVE) { 58 | this.activateMoveViewport(); 59 | } else { 60 | this.deactivateMoveViewport(); 61 | } 62 | }); 63 | 64 | element.appendChild(this.app.view); 65 | this.element = element; 66 | 67 | const defaultWidth = element.offsetWidth; 68 | const defaultHeight = element.offsetHeight; 69 | 70 | let params = (new URL(document.location)).searchParams; 71 | let w = parseInt(params.get("w") || 0, 10) || defaultWidth; 72 | let h = parseInt(params.get("h") || 0, 10) || defaultHeight; 73 | 74 | this.new(w, h); 75 | this._bindListeners(); 76 | 77 | window.addEventListener('resize', () => { 78 | // update the canvas so we get the full viewport 79 | this.app.ticker.update(); 80 | }); 81 | 82 | // load autosave, if we have one 83 | const autosaved = this.autosave.load() 84 | if (autosaved) { 85 | this._loadImage(autosaved) 86 | } 87 | } 88 | 89 | _bindListeners() { 90 | // global actions 91 | store.listen('new', (dimensions) => { 92 | dimensions = dimensions || store.get('dimensions') || {}; 93 | const { width, height } = dimensions; 94 | this.autosave.clear(); 95 | this.new(width || 800, height || 600); 96 | }) 97 | 98 | store.listen('new_fit', () => { 99 | this.autosave.clear(); 100 | this.new(this.element.offsetWidth, this.element.offsetHeight); 101 | }) 102 | 103 | store.listen('resize', (dim) => { 104 | this.resize(dim.width, dim.height); 105 | }) 106 | 107 | store.listen('stretch', (dim) => { 108 | this.stretch(dim.width, dim.height); 109 | }) 110 | 111 | store.listen('center', () => { 112 | this.resetViewport(); 113 | }) 114 | 115 | store.listen('mirror', () => { 116 | this.mirrorHorizontal(); 117 | }) 118 | 119 | store.listen('flip', () => { 120 | this.flipVertical(); 121 | }) 122 | 123 | store.listen('save', () => { 124 | this.saveImage(); 125 | }) 126 | 127 | store.listen('import', () => { 128 | this.importImage(); 129 | }) 130 | 131 | store.listen('export', () => { 132 | this.exportImage(); 133 | }) 134 | 135 | store.listen('on_undo_complete', () => { 136 | this.autosave.save(); 137 | }) 138 | 139 | store.listen('on_redo_complete', () => { 140 | this.autosave.save(); 141 | }) 142 | 143 | store.subscribe('resources', (res) => { 144 | this._onLoadResources(res); 145 | }) 146 | } 147 | 148 | getViewport() { 149 | return this.viewport; 150 | } 151 | 152 | resetViewport() { 153 | this.viewport.scaled = 1; 154 | this.viewport.left = - this.app.renderer.width / 2 + this.renderTextureSprite.width / 2; 155 | this.viewport.top = - this.app.renderer.height / 2 + this.renderTextureSprite.height / 2; 156 | this.app.ticker.update(); 157 | } 158 | 159 | activateMoveViewport() { 160 | // activate plugins 161 | this.viewport 162 | .drag() 163 | .pinch() 164 | } 165 | 166 | deactivateMoveViewport() { 167 | this.viewport.plugins.remove('pinch'); 168 | this.viewport.plugins.remove('drag'); 169 | } 170 | 171 | _onLoadResources(res) { 172 | this.shader = res.shader; 173 | this.renderTextureSprite.filters = [this.shader]; 174 | this.app.ticker.update(); 175 | } 176 | 177 | _createSurface(width, height) { 178 | this.renderTexture = PIXI.RenderTexture.create(width || this.app.screen.width, height || this.app.screen.height); 179 | this.renderTextureSprite = new PIXI.Sprite(this.renderTexture); 180 | 181 | this.viewport.addChild(this.renderTextureSprite); 182 | 183 | if (!this.app.stage.interactive) { 184 | this.app.stage.interactive = true; 185 | this.app.stage.on('pointerdown', this.pointerDown.bind(this)); 186 | this.app.stage.on('pointerup', this.pointerUp.bind(this)); 187 | this.app.stage.on('pointerupoutside', this.pointerUp.bind(this)); 188 | this.app.stage.on('pointermove', this.pointerMove.bind(this)); 189 | } 190 | 191 | if (this.shader) { 192 | this.renderTextureSprite.filters = [this.shader] 193 | } 194 | 195 | this.app.ticker.update(); 196 | 197 | } 198 | 199 | new(width, height) { 200 | if (!this.renderTexture) { 201 | this._createSurface(width, height); 202 | store.update('dimensions', { 203 | width, 204 | height 205 | }) 206 | } else { 207 | this.resize(width, height); 208 | this.render(EMPTY_SPRITE, this.renderTexture, true); 209 | } 210 | 211 | store.update('dirty', false); 212 | 213 | this.undo.reset(); 214 | this.resetViewport(); 215 | } 216 | 217 | importImage() { 218 | console.log('begin import image') 219 | const self = this; // hooray for waterfall chaining. 220 | 221 | const ext = ['png', 'jpg', 'jpeg'] 222 | let input = document.getElementById('hidden').getElementsByTagName('INPUT')[0] 223 | if (!input) { 224 | input = document.createElement('input') 225 | document.getElementById('hidden').appendChild(input); 226 | 227 | input.type = 'file' 228 | input.accept = '.png, .jpg, .jpeg' 229 | input.setAttribute('multiple', 'multiple') 230 | input.addEventListener('change', (e) => { 231 | for (const file of e.target.files) { 232 | const pieces = file.name.toLowerCase().split('.') 233 | const fileExt = pieces[pieces.length - 1] 234 | if (ext.indexOf(fileExt) < 0) { 235 | console.log('Loaded an invalid file', file.name) 236 | continue 237 | } 238 | 239 | // do something with the file. 240 | const reader = new FileReader() 241 | reader.addEventListener("load", function (ev) { 242 | self._loadImage(reader.result, () => { 243 | document.getElementById('hidden').removeChild(input) 244 | }); 245 | }, false); 246 | reader.readAsDataURL(file) 247 | } 248 | }); 249 | } 250 | input.click() 251 | } 252 | 253 | _loadImage(imgSrc, callback) { 254 | const self = this; 255 | // convert image file to base64 string 256 | const img = new Image(); 257 | img.src = imgSrc; 258 | img.onload = (ev2) => { 259 | const q = new RgbQuant({ 260 | palette: rgbQuantColors(), 261 | }) 262 | 263 | const pixelArray = q.reduce(img); 264 | const newTexture = PIXI.Texture.fromBuffer(pixelArray, img.width, img.height); 265 | const newTextureSprite = new PIXI.Sprite(newTexture); 266 | self.resize(img.width, img.height); 267 | self.app.renderer.render(newTextureSprite, self.renderTexture, true, null, false); 268 | 269 | self.undo.reset(); 270 | 271 | // reset viewport will update the ticker and refresh the view, 272 | // so we don't have to do it again. 273 | self.resetViewport(); 274 | if (callback) { 275 | callback(); 276 | } 277 | } 278 | } 279 | 280 | getCanvas() { 281 | return this.app.renderer.extract.canvas(this.renderTexture); 282 | } 283 | 284 | saveImage() { 285 | this.getCanvas().toBlob(function (b) { 286 | const timestamp = Date.now().toString(); 287 | var a = document.createElement('a'); 288 | document.body.append(a); 289 | a.download = `strike-raw-${timestamp}.png`; 290 | a.href = URL.createObjectURL(b); 291 | a.click(); 292 | a.remove(); 293 | store.update('dirty', false); 294 | }, 'image/png'); 295 | } 296 | 297 | exportImage() { 298 | const snapshot = new PIXI.Sprite(this.renderTexture); 299 | snapshot.filters = [this.shader]; 300 | this.app.renderer.extract.canvas(snapshot).toBlob(function (b) { 301 | const timestamp = Date.now().toString(); 302 | var a = document.createElement('a'); 303 | document.body.append(a); 304 | a.download = `strike-export-${timestamp}.png`; 305 | a.href = URL.createObjectURL(b); 306 | a.click(); 307 | a.remove(); 308 | }, 'image/png'); 309 | } 310 | 311 | resize(width, height) { 312 | if (!this.renderTexture) { 313 | return; 314 | } 315 | if (width === this.renderTexture.width && height === this.renderTexture.height) { 316 | return; // do nothing 317 | } 318 | store.update('dimensions', { 319 | width, 320 | height 321 | }) 322 | 323 | const snapshotTexture = this._copyRenderTexture(); 324 | this.renderTexture.resize(width, height, true); 325 | this.render(new PIXI.Sprite(snapshotTexture), this.renderTexture, true); 326 | this.addUndoable('RESIZE', { 327 | width, 328 | height 329 | }) 330 | } 331 | 332 | stretch(width, height) { 333 | if (!this.renderTexture) { 334 | return; 335 | } 336 | if (width === this.renderTexture.width && height === this.renderTexture.height) { 337 | return; // do nothing 338 | } 339 | store.update('dimensions', { 340 | width, 341 | height 342 | }) 343 | 344 | const snapshotTexture = this._copyRenderTexture(); 345 | const stretchedSnapshot = new PIXI.Sprite(snapshotTexture) 346 | stretchedSnapshot.scale.x = width / this.renderTexture.width; 347 | stretchedSnapshot.scale.y = height / this.renderTexture.height; 348 | this.renderTexture.resize(width, height, true); 349 | this.render(stretchedSnapshot, this.renderTexture, true); 350 | this.addUndoable('STRETCH', { 351 | width, 352 | height 353 | }); 354 | } 355 | 356 | mirrorHorizontal() { 357 | const snapshotTexture = this._copyRenderTexture(); 358 | const mirrored = new PIXI.Sprite(snapshotTexture); 359 | mirrored.scale.x = -1 360 | mirrored.position.x = this.renderTexture.width; 361 | this.render(mirrored, this.renderTexture, true); 362 | this.addUndoable('MIRROR', undefined, false); 363 | } 364 | 365 | flipVertical() { 366 | const snapshotTexture = this._copyRenderTexture(); 367 | const mirrored = new PIXI.Sprite(snapshotTexture); 368 | mirrored.scale.y = -1 369 | mirrored.position.y = this.renderTexture.height; 370 | this.render(mirrored, this.renderTexture, true); 371 | this.addUndoable('FLIP', undefined, false); 372 | } 373 | 374 | _copyRenderTexture() { 375 | const snapshotTexture = PIXI.RenderTexture.create(this.renderTexture.width, this.renderTexture.height); 376 | const snapSprite = new PIXI.Sprite(this.renderTexture); 377 | this.app.renderer.render(snapSprite, snapshotTexture, true, null, false); 378 | return snapshotTexture; 379 | } 380 | 381 | pointerMove(event) { 382 | if (!this.currentTool) { 383 | return; 384 | } 385 | if (this.dragging) { 386 | this.currentTool.move(this.app.renderer, this.renderTexture, event, this); 387 | // TODO: we can make this more efficient by only calling update on the tools that actually perform 388 | // a render. This involves restructuring some logic, though. 389 | this.app.ticker.update(); 390 | } 391 | } 392 | 393 | pointerDown(event) { 394 | if (!this.currentTool) { 395 | return; 396 | } 397 | this.dragging = true; 398 | this.currentTool.begin(this.app.renderer, this.renderTexture, event, this); 399 | // TODO: we can make this more efficient by only calling update on the tools that actually perform 400 | // a render. This involves restructuring some logic, though. 401 | this.app.ticker.update(); 402 | } 403 | 404 | pointerUp(event) { 405 | if (!this.currentTool) { 406 | return; 407 | } 408 | if (!this.dragging) { 409 | return; 410 | } 411 | this.dragging = false; 412 | 413 | if (!this.renderTextureSprite) { 414 | return; 415 | } 416 | this.currentTool.end(this.app.renderer, this.renderTexture, event, this); 417 | // TODO: we can make this more efficient by only calling update on the tools that actually perform 418 | // a render. This involves restructuring some logic, though. 419 | this.app.ticker.update(); 420 | } 421 | 422 | setToSnapshot(texture) { 423 | const newTextureSprite = new PIXI.Sprite(texture); 424 | if (this.renderTexture.width !== texture.width || this.renderTexture.height !== texture.height) { 425 | this.renderTexture.resize(texture.width, texture.height, true); 426 | } 427 | this.render(newTextureSprite, this.renderTexture, true); 428 | } 429 | 430 | addUndoable(toolName, op, createSnapshot) { 431 | this.undo.addUndoable(toolName, op, createSnapshot); 432 | this.autosave.save(); 433 | store.update('dirty', true); 434 | } 435 | 436 | applySavedOperation(op) { 437 | const tool = tools.get(op.toolName); 438 | if (!tool) { 439 | console.log('Operation had invalid tool: ', op); 440 | return; 441 | } 442 | 443 | // TODO: apply saved operation per tool. 444 | tool.applyOperation(op.operation, this.app.renderer, this.renderTexture); 445 | this.app.ticker.update(); 446 | } 447 | 448 | render(obj, texture, redraw) { 449 | this.app.renderer.render(obj, texture, redraw, null, false); 450 | this.app.ticker.update(); 451 | } 452 | } 453 | 454 | export default Artwork; -------------------------------------------------------------------------------- /styles/fonts/icomoon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/vendor/rgbquant.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Leon Sorokin 3 | * All rights reserved. (MIT Licensed) 4 | * 5 | * RgbQuant.js - an image quantization lib 6 | */ 7 | 8 | (function(){ 9 | function RgbQuant(opts) { 10 | opts = opts || {}; 11 | 12 | // 1 = by global population, 2 = subregion population threshold 13 | this.method = opts.method || 2; 14 | // desired final palette size 15 | this.colors = opts.colors || 256; 16 | // # of highest-frequency colors to start with for palette reduction 17 | this.initColors = opts.initColors || 4096; 18 | // color-distance threshold for initial reduction pass 19 | this.initDist = opts.initDist || 0.01; 20 | // subsequent passes threshold 21 | this.distIncr = opts.distIncr || 0.005; 22 | // palette grouping 23 | this.hueGroups = opts.hueGroups || 10; 24 | this.satGroups = opts.satGroups || 10; 25 | this.lumGroups = opts.lumGroups || 10; 26 | // if > 0, enables hues stats and min-color retention per group 27 | this.minHueCols = opts.minHueCols || 0; 28 | // HueStats instance 29 | this.hueStats = this.minHueCols ? new HueStats(this.hueGroups, this.minHueCols) : null; 30 | 31 | // subregion partitioning box size 32 | this.boxSize = opts.boxSize || [64,64]; 33 | // number of same pixels required within box for histogram inclusion 34 | this.boxPxls = opts.boxPxls || 2; 35 | // palette locked indicator 36 | this.palLocked = false; 37 | // palette sort order 38 | // this.sortPal = ['hue-','lum-','sat-']; 39 | 40 | // dithering/error diffusion kernel name 41 | this.dithKern = opts.dithKern || null; 42 | // dither serpentine pattern 43 | this.dithSerp = opts.dithSerp || false; 44 | // minimum color difference (0-1) needed to dither 45 | this.dithDelta = opts.dithDelta || 0; 46 | 47 | // accumulated histogram 48 | this.histogram = {}; 49 | // palette - rgb triplets 50 | this.idxrgb = opts.palette ? opts.palette.slice(0) : []; 51 | // palette - int32 vals 52 | this.idxi32 = []; 53 | // reverse lookup {i32:idx} 54 | this.i32idx = {}; 55 | // {i32:rgb} 56 | this.i32rgb = {}; 57 | // enable color caching (also incurs overhead of cache misses and cache building) 58 | this.useCache = opts.useCache !== false; 59 | // min color occurance count needed to qualify for caching 60 | this.cacheFreq = opts.cacheFreq || 10; 61 | // allows pre-defined palettes to be re-indexed (enabling palette compacting and sorting) 62 | this.reIndex = opts.reIndex || this.idxrgb.length == 0; 63 | // selection of color-distance equation 64 | this.colorDist = opts.colorDist == "manhattan" ? distManhattan : distEuclidean; 65 | 66 | // if pre-defined palette, build lookups 67 | if (this.idxrgb.length > 0) { 68 | var self = this; 69 | this.idxrgb.forEach(function(rgb, i) { 70 | var i32 = ( 71 | (255 << 24) | // alpha 72 | (rgb[2] << 16) | // blue 73 | (rgb[1] << 8) | // green 74 | rgb[0] // red 75 | ) >>> 0; 76 | 77 | self.idxi32[i] = i32; 78 | self.i32idx[i32] = i; 79 | self.i32rgb[i32] = rgb; 80 | }); 81 | } 82 | } 83 | 84 | // gathers histogram info 85 | RgbQuant.prototype.sample = function sample(img, width) { 86 | if (this.palLocked) 87 | throw "Cannot sample additional images, palette already assembled."; 88 | 89 | var data = getImageData(img, width); 90 | 91 | switch (this.method) { 92 | case 1: this.colorStats1D(data.buf32); break; 93 | case 2: this.colorStats2D(data.buf32, data.width); break; 94 | } 95 | }; 96 | 97 | // image quantizer 98 | // todo: memoize colors here also 99 | // @retType: 1 - Uint8Array (default), 2 - Indexed array, 3 - Match @img type (unimplemented, todo) 100 | RgbQuant.prototype.reduce = function reduce(img, retType, dithKern, dithSerp) { 101 | if (!this.palLocked) 102 | this.buildPal(); 103 | 104 | dithKern = dithKern || this.dithKern; 105 | dithSerp = typeof dithSerp != "undefined" ? dithSerp : this.dithSerp; 106 | 107 | retType = retType || 1; 108 | 109 | // reduce w/dither 110 | if (dithKern) 111 | var out32 = this.dither(img, dithKern, dithSerp); 112 | else { 113 | var data = getImageData(img), 114 | buf32 = data.buf32, 115 | len = buf32.length, 116 | out32 = new Uint32Array(len); 117 | 118 | for (var i = 0; i < len; i++) { 119 | var i32 = buf32[i]; 120 | out32[i] = this.nearestColor(i32); 121 | } 122 | } 123 | 124 | if (retType == 1) 125 | return new Uint8Array(out32.buffer); 126 | 127 | if (retType == 2) { 128 | var out = [], 129 | len = out32.length; 130 | 131 | for (var i = 0; i < len; i++) { 132 | var i32 = out32[i]; 133 | out[i] = this.i32idx[i32]; 134 | } 135 | 136 | return out; 137 | } 138 | }; 139 | 140 | // adapted from http://jsbin.com/iXofIji/2/edit by PAEz 141 | RgbQuant.prototype.dither = function(img, kernel, serpentine) { 142 | // http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/ 143 | var kernels = { 144 | FloydSteinberg: [ 145 | [7 / 16, 1, 0], 146 | [3 / 16, -1, 1], 147 | [5 / 16, 0, 1], 148 | [1 / 16, 1, 1] 149 | ], 150 | FalseFloydSteinberg: [ 151 | [3 / 8, 1, 0], 152 | [3 / 8, 0, 1], 153 | [2 / 8, 1, 1] 154 | ], 155 | Stucki: [ 156 | [8 / 42, 1, 0], 157 | [4 / 42, 2, 0], 158 | [2 / 42, -2, 1], 159 | [4 / 42, -1, 1], 160 | [8 / 42, 0, 1], 161 | [4 / 42, 1, 1], 162 | [2 / 42, 2, 1], 163 | [1 / 42, -2, 2], 164 | [2 / 42, -1, 2], 165 | [4 / 42, 0, 2], 166 | [2 / 42, 1, 2], 167 | [1 / 42, 2, 2] 168 | ], 169 | Atkinson: [ 170 | [1 / 8, 1, 0], 171 | [1 / 8, 2, 0], 172 | [1 / 8, -1, 1], 173 | [1 / 8, 0, 1], 174 | [1 / 8, 1, 1], 175 | [1 / 8, 0, 2] 176 | ], 177 | Jarvis: [ // Jarvis, Judice, and Ninke / JJN? 178 | [7 / 48, 1, 0], 179 | [5 / 48, 2, 0], 180 | [3 / 48, -2, 1], 181 | [5 / 48, -1, 1], 182 | [7 / 48, 0, 1], 183 | [5 / 48, 1, 1], 184 | [3 / 48, 2, 1], 185 | [1 / 48, -2, 2], 186 | [3 / 48, -1, 2], 187 | [5 / 48, 0, 2], 188 | [3 / 48, 1, 2], 189 | [1 / 48, 2, 2] 190 | ], 191 | Burkes: [ 192 | [8 / 32, 1, 0], 193 | [4 / 32, 2, 0], 194 | [2 / 32, -2, 1], 195 | [4 / 32, -1, 1], 196 | [8 / 32, 0, 1], 197 | [4 / 32, 1, 1], 198 | [2 / 32, 2, 1], 199 | ], 200 | Sierra: [ 201 | [5 / 32, 1, 0], 202 | [3 / 32, 2, 0], 203 | [2 / 32, -2, 1], 204 | [4 / 32, -1, 1], 205 | [5 / 32, 0, 1], 206 | [4 / 32, 1, 1], 207 | [2 / 32, 2, 1], 208 | [2 / 32, -1, 2], 209 | [3 / 32, 0, 2], 210 | [2 / 32, 1, 2], 211 | ], 212 | TwoSierra: [ 213 | [4 / 16, 1, 0], 214 | [3 / 16, 2, 0], 215 | [1 / 16, -2, 1], 216 | [2 / 16, -1, 1], 217 | [3 / 16, 0, 1], 218 | [2 / 16, 1, 1], 219 | [1 / 16, 2, 1], 220 | ], 221 | SierraLite: [ 222 | [2 / 4, 1, 0], 223 | [1 / 4, -1, 1], 224 | [1 / 4, 0, 1], 225 | ], 226 | }; 227 | 228 | if (!kernel || !kernels[kernel]) { 229 | throw 'Unknown dithering kernel: ' + kernel; 230 | } 231 | 232 | var ds = kernels[kernel]; 233 | 234 | var data = getImageData(img), 235 | // buf8 = data.buf8, 236 | buf32 = data.buf32, 237 | width = data.width, 238 | height = data.height, 239 | len = buf32.length; 240 | 241 | var dir = serpentine ? -1 : 1; 242 | 243 | for (var y = 0; y < height; y++) { 244 | if (serpentine) 245 | dir = dir * -1; 246 | 247 | var lni = y * width; 248 | 249 | for (var x = (dir == 1 ? 0 : width - 1), xend = (dir == 1 ? width : 0); x !== xend; x += dir) { 250 | // Image pixel 251 | var idx = lni + x, 252 | i32 = buf32[idx], 253 | r1 = (i32 & 0xff), 254 | g1 = (i32 & 0xff00) >> 8, 255 | b1 = (i32 & 0xff0000) >> 16; 256 | 257 | // Reduced pixel 258 | var i32x = this.nearestColor(i32), 259 | r2 = (i32x & 0xff), 260 | g2 = (i32x & 0xff00) >> 8, 261 | b2 = (i32x & 0xff0000) >> 16; 262 | 263 | buf32[idx] = 264 | (255 << 24) | // alpha 265 | (b2 << 16) | // blue 266 | (g2 << 8) | // green 267 | r2; 268 | 269 | // dithering strength 270 | if (this.dithDelta) { 271 | var dist = this.colorDist([r1, g1, b1], [r2, g2, b2]); 272 | if (dist < this.dithDelta) 273 | continue; 274 | } 275 | 276 | // Component distance 277 | var er = r1 - r2, 278 | eg = g1 - g2, 279 | eb = b1 - b2; 280 | 281 | for (var i = (dir == 1 ? 0 : ds.length - 1), end = (dir == 1 ? ds.length : 0); i !== end; i += dir) { 282 | var x1 = ds[i][1] * dir, 283 | y1 = ds[i][2]; 284 | 285 | var lni2 = y1 * width; 286 | 287 | if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) { 288 | var d = ds[i][0]; 289 | var idx2 = idx + (lni2 + x1); 290 | 291 | var r3 = (buf32[idx2] & 0xff), 292 | g3 = (buf32[idx2] & 0xff00) >> 8, 293 | b3 = (buf32[idx2] & 0xff0000) >> 16; 294 | 295 | var r4 = Math.max(0, Math.min(255, r3 + er * d)), 296 | g4 = Math.max(0, Math.min(255, g3 + eg * d)), 297 | b4 = Math.max(0, Math.min(255, b3 + eb * d)); 298 | 299 | buf32[idx2] = 300 | (255 << 24) | // alpha 301 | (b4 << 16) | // blue 302 | (g4 << 8) | // green 303 | r4; // red 304 | } 305 | } 306 | } 307 | } 308 | 309 | return buf32; 310 | }; 311 | 312 | // reduces histogram to palette, remaps & memoizes reduced colors 313 | RgbQuant.prototype.buildPal = function buildPal(noSort) { 314 | if (this.palLocked || this.idxrgb.length > 0 && this.idxrgb.length <= this.colors) return; 315 | 316 | var histG = this.histogram, 317 | sorted = sortedHashKeys(histG, true); 318 | 319 | if (sorted.length == 0) 320 | throw "Nothing has been sampled, palette cannot be built."; 321 | 322 | switch (this.method) { 323 | case 1: 324 | var cols = this.initColors, 325 | last = sorted[cols - 1], 326 | freq = histG[last]; 327 | 328 | var idxi32 = sorted.slice(0, cols); 329 | 330 | // add any cut off colors with same freq as last 331 | var pos = cols, len = sorted.length; 332 | while (pos < len && histG[sorted[pos]] == freq) 333 | idxi32.push(sorted[pos++]); 334 | 335 | // inject min huegroup colors 336 | if (this.hueStats) 337 | this.hueStats.inject(idxi32); 338 | 339 | break; 340 | case 2: 341 | var idxi32 = sorted; 342 | break; 343 | } 344 | 345 | // int32-ify values 346 | idxi32 = idxi32.map(function(v){return +v;}); 347 | 348 | this.reducePal(idxi32); 349 | 350 | if (!noSort && this.reIndex) 351 | this.sortPal(); 352 | 353 | // build cache of top histogram colors 354 | if (this.useCache) 355 | this.cacheHistogram(idxi32); 356 | 357 | this.palLocked = true; 358 | }; 359 | 360 | RgbQuant.prototype.palette = function palette(tuples, noSort) { 361 | this.buildPal(noSort); 362 | return tuples ? this.idxrgb : new Uint8Array((new Uint32Array(this.idxi32)).buffer); 363 | }; 364 | 365 | RgbQuant.prototype.prunePal = function prunePal(keep) { 366 | var i32; 367 | 368 | for (var j = 0; j < this.idxrgb.length; j++) { 369 | if (!keep[j]) { 370 | i32 = this.idxi32[j]; 371 | this.idxrgb[j] = null; 372 | this.idxi32[j] = null; 373 | delete this.i32idx[i32]; 374 | } 375 | } 376 | 377 | // compact 378 | if (this.reIndex) { 379 | var idxrgb = [], 380 | idxi32 = [], 381 | i32idx = {}; 382 | 383 | for (var j = 0, i = 0; j < this.idxrgb.length; j++) { 384 | if (this.idxrgb[j]) { 385 | i32 = this.idxi32[j]; 386 | idxrgb[i] = this.idxrgb[j]; 387 | i32idx[i32] = i; 388 | idxi32[i] = i32; 389 | i++; 390 | } 391 | } 392 | 393 | this.idxrgb = idxrgb; 394 | this.idxi32 = idxi32; 395 | this.i32idx = i32idx; 396 | } 397 | }; 398 | 399 | // reduces similar colors from an importance-sorted Uint32 rgba array 400 | RgbQuant.prototype.reducePal = function reducePal(idxi32) { 401 | // if pre-defined palette's length exceeds target 402 | if (this.idxrgb.length > this.colors) { 403 | // quantize histogram to existing palette 404 | var len = idxi32.length, keep = {}, uniques = 0, idx, pruned = false; 405 | 406 | for (var i = 0; i < len; i++) { 407 | // palette length reached, unset all remaining colors (sparse palette) 408 | if (uniques == this.colors && !pruned) { 409 | this.prunePal(keep); 410 | pruned = true; 411 | } 412 | 413 | idx = this.nearestIndex(idxi32[i]); 414 | 415 | if (uniques < this.colors && !keep[idx]) { 416 | keep[idx] = true; 417 | uniques++; 418 | } 419 | } 420 | 421 | if (!pruned) { 422 | this.prunePal(keep); 423 | pruned = true; 424 | } 425 | } 426 | // reduce histogram to create initial palette 427 | else { 428 | // build full rgb palette 429 | var idxrgb = idxi32.map(function(i32) { 430 | return [ 431 | (i32 & 0xff), 432 | (i32 & 0xff00) >> 8, 433 | (i32 & 0xff0000) >> 16, 434 | ]; 435 | }); 436 | 437 | var len = idxrgb.length, 438 | palLen = len, 439 | thold = this.initDist; 440 | 441 | // palette already at or below desired length 442 | if (palLen > this.colors) { 443 | while (palLen > this.colors) { 444 | var memDist = []; 445 | 446 | // iterate palette 447 | for (var i = 0; i < len; i++) { 448 | var pxi = idxrgb[i], i32i = idxi32[i]; 449 | if (!pxi) continue; 450 | 451 | for (var j = i + 1; j < len; j++) { 452 | var pxj = idxrgb[j], i32j = idxi32[j]; 453 | if (!pxj) continue; 454 | 455 | var dist = this.colorDist(pxi, pxj); 456 | 457 | if (dist < thold) { 458 | // store index,rgb,dist 459 | memDist.push([j, pxj, i32j, dist]); 460 | 461 | // kill squashed value 462 | delete(idxrgb[j]); 463 | palLen--; 464 | } 465 | } 466 | } 467 | 468 | // palette reduction pass 469 | // console.log("palette length: " + palLen); 470 | 471 | // if palette is still much larger than target, increment by larger initDist 472 | thold += (palLen > this.colors * 3) ? this.initDist : this.distIncr; 473 | } 474 | 475 | // if palette is over-reduced, re-add removed colors with largest distances from last round 476 | if (palLen < this.colors) { 477 | // sort descending 478 | sort.call(memDist, function(a,b) { 479 | return b[3] - a[3]; 480 | }); 481 | 482 | var k = 0; 483 | while (palLen < this.colors) { 484 | // re-inject rgb into final palette 485 | idxrgb[memDist[k][0]] = memDist[k][1]; 486 | 487 | palLen++; 488 | k++; 489 | } 490 | } 491 | } 492 | 493 | var len = idxrgb.length; 494 | for (var i = 0; i < len; i++) { 495 | if (!idxrgb[i]) continue; 496 | 497 | this.idxrgb.push(idxrgb[i]); 498 | this.idxi32.push(idxi32[i]); 499 | 500 | this.i32idx[idxi32[i]] = this.idxi32.length - 1; 501 | this.i32rgb[idxi32[i]] = idxrgb[i]; 502 | } 503 | } 504 | }; 505 | 506 | // global top-population 507 | RgbQuant.prototype.colorStats1D = function colorStats1D(buf32) { 508 | var histG = this.histogram, 509 | num = 0, col, 510 | len = buf32.length; 511 | 512 | for (var i = 0; i < len; i++) { 513 | col = buf32[i]; 514 | 515 | // skip transparent 516 | if ((col & 0xff000000) >> 24 == 0) continue; 517 | 518 | // collect hue stats 519 | if (this.hueStats) 520 | this.hueStats.check(col); 521 | 522 | if (col in histG) 523 | histG[col]++; 524 | else 525 | histG[col] = 1; 526 | } 527 | }; 528 | 529 | // population threshold within subregions 530 | // FIXME: this can over-reduce (few/no colors same?), need a way to keep 531 | // important colors that dont ever reach local thresholds (gradients?) 532 | RgbQuant.prototype.colorStats2D = function colorStats2D(buf32, width) { 533 | var boxW = this.boxSize[0], 534 | boxH = this.boxSize[1], 535 | area = boxW * boxH, 536 | boxes = makeBoxes(width, buf32.length / width, boxW, boxH), 537 | histG = this.histogram, 538 | self = this; 539 | 540 | boxes.forEach(function(box) { 541 | var effc = Math.max(Math.round((box.w * box.h) / area) * self.boxPxls, 2), 542 | histL = {}, col; 543 | 544 | iterBox(box, width, function(i) { 545 | col = buf32[i]; 546 | 547 | // skip transparent 548 | if ((col & 0xff000000) >> 24 == 0) return; 549 | 550 | // collect hue stats 551 | if (self.hueStats) 552 | self.hueStats.check(col); 553 | 554 | if (col in histG) 555 | histG[col]++; 556 | else if (col in histL) { 557 | if (++histL[col] >= effc) 558 | histG[col] = histL[col]; 559 | } 560 | else 561 | histL[col] = 1; 562 | }); 563 | }); 564 | 565 | if (this.hueStats) 566 | this.hueStats.inject(histG); 567 | }; 568 | 569 | // TODO: group very low lum and very high lum colors 570 | // TODO: pass custom sort order 571 | RgbQuant.prototype.sortPal = function sortPal() { 572 | var self = this; 573 | 574 | this.idxi32.sort(function(a,b) { 575 | var idxA = self.i32idx[a], 576 | idxB = self.i32idx[b], 577 | rgbA = self.idxrgb[idxA], 578 | rgbB = self.idxrgb[idxB]; 579 | 580 | var hslA = rgb2hsl(rgbA[0],rgbA[1],rgbA[2]), 581 | hslB = rgb2hsl(rgbB[0],rgbB[1],rgbB[2]); 582 | 583 | // sort all grays + whites together 584 | var hueA = (rgbA[0] == rgbA[1] && rgbA[1] == rgbA[2]) ? -1 : hueGroup(hslA.h, self.hueGroups); 585 | var hueB = (rgbB[0] == rgbB[1] && rgbB[1] == rgbB[2]) ? -1 : hueGroup(hslB.h, self.hueGroups); 586 | 587 | var hueDiff = hueB - hueA; 588 | if (hueDiff) return -hueDiff; 589 | 590 | var lumDiff = lumGroup(+hslB.l.toFixed(2)) - lumGroup(+hslA.l.toFixed(2)); 591 | if (lumDiff) return -lumDiff; 592 | 593 | var satDiff = satGroup(+hslB.s.toFixed(2)) - satGroup(+hslA.s.toFixed(2)); 594 | if (satDiff) return -satDiff; 595 | }); 596 | 597 | // sync idxrgb & i32idx 598 | this.idxi32.forEach(function(i32, i) { 599 | self.idxrgb[i] = self.i32rgb[i32]; 600 | self.i32idx[i32] = i; 601 | }); 602 | }; 603 | 604 | // TOTRY: use HUSL - http://boronine.com/husl/ 605 | RgbQuant.prototype.nearestColor = function nearestColor(i32) { 606 | var idx = this.nearestIndex(i32); 607 | return idx === null ? 0 : this.idxi32[idx]; 608 | }; 609 | 610 | // TOTRY: use HUSL - http://boronine.com/husl/ 611 | RgbQuant.prototype.nearestIndex = function nearestIndex(i32) { 612 | // alpha 0 returns null index 613 | if ((i32 & 0xff000000) >> 24 == 0) 614 | return null; 615 | 616 | if (this.useCache && (""+i32) in this.i32idx) 617 | return this.i32idx[i32]; 618 | 619 | var min = 1000, 620 | idx, 621 | rgb = [ 622 | (i32 & 0xff), 623 | (i32 & 0xff00) >> 8, 624 | (i32 & 0xff0000) >> 16, 625 | ], 626 | len = this.idxrgb.length; 627 | 628 | for (var i = 0; i < len; i++) { 629 | if (!this.idxrgb[i]) continue; // sparse palettes 630 | 631 | var dist = this.colorDist(rgb, this.idxrgb[i]); 632 | 633 | if (dist < min) { 634 | min = dist; 635 | idx = i; 636 | } 637 | } 638 | 639 | return idx; 640 | }; 641 | 642 | RgbQuant.prototype.cacheHistogram = function cacheHistogram(idxi32) { 643 | for (var i = 0, i32 = idxi32[i]; i < idxi32.length && this.histogram[i32] >= this.cacheFreq; i32 = idxi32[i++]) 644 | this.i32idx[i32] = this.nearestIndex(i32); 645 | }; 646 | 647 | function HueStats(numGroups, minCols) { 648 | this.numGroups = numGroups; 649 | this.minCols = minCols; 650 | this.stats = {}; 651 | 652 | for (var i = -1; i < numGroups; i++) 653 | this.stats[i] = {num: 0, cols: []}; 654 | 655 | this.groupsFull = 0; 656 | } 657 | 658 | HueStats.prototype.check = function checkHue(i32) { 659 | if (this.groupsFull == this.numGroups + 1) 660 | this.check = function() {return;}; 661 | 662 | var r = (i32 & 0xff), 663 | g = (i32 & 0xff00) >> 8, 664 | b = (i32 & 0xff0000) >> 16, 665 | hg = (r == g && g == b) ? -1 : hueGroup(rgb2hsl(r,g,b).h, this.numGroups), 666 | gr = this.stats[hg], 667 | min = this.minCols; 668 | 669 | gr.num++; 670 | 671 | if (gr.num > min) 672 | return; 673 | if (gr.num == min) 674 | this.groupsFull++; 675 | 676 | if (gr.num <= min) 677 | this.stats[hg].cols.push(i32); 678 | }; 679 | 680 | HueStats.prototype.inject = function injectHues(histG) { 681 | for (var i = -1; i < this.numGroups; i++) { 682 | if (this.stats[i].num <= this.minCols) { 683 | switch (typeOf(histG)) { 684 | case "Array": 685 | this.stats[i].cols.forEach(function(col){ 686 | if (histG.indexOf(col) == -1) 687 | histG.push(col); 688 | }); 689 | break; 690 | case "Object": 691 | this.stats[i].cols.forEach(function(col){ 692 | if (!histG[col]) 693 | histG[col] = 1; 694 | else 695 | histG[col]++; 696 | }); 697 | break; 698 | } 699 | } 700 | } 701 | }; 702 | 703 | // Rec. 709 (sRGB) luma coef 704 | var Pr = .2126, 705 | Pg = .7152, 706 | Pb = .0722; 707 | 708 | // http://alienryderflex.com/hsp.html 709 | function rgb2lum(r,g,b) { 710 | return Math.sqrt( 711 | Pr * r*r + 712 | Pg * g*g + 713 | Pb * b*b 714 | ); 715 | } 716 | 717 | var rd = 255, 718 | gd = 255, 719 | bd = 255; 720 | 721 | var euclMax = Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd); 722 | // perceptual Euclidean color distance 723 | function distEuclidean(rgb0, rgb1) { 724 | var rd = rgb1[0]-rgb0[0], 725 | gd = rgb1[1]-rgb0[1], 726 | bd = rgb1[2]-rgb0[2]; 727 | 728 | return Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd) / euclMax; 729 | } 730 | 731 | var manhMax = Pr*rd + Pg*gd + Pb*bd; 732 | // perceptual Manhattan color distance 733 | function distManhattan(rgb0, rgb1) { 734 | var rd = Math.abs(rgb1[0]-rgb0[0]), 735 | gd = Math.abs(rgb1[1]-rgb0[1]), 736 | bd = Math.abs(rgb1[2]-rgb0[2]); 737 | 738 | return (Pr*rd + Pg*gd + Pb*bd) / manhMax; 739 | } 740 | 741 | // http://rgb2hsl.nichabi.com/javascript-function.php 742 | function rgb2hsl(r, g, b) { 743 | var max, min, h, s, l, d; 744 | r /= 255; 745 | g /= 255; 746 | b /= 255; 747 | max = Math.max(r, g, b); 748 | min = Math.min(r, g, b); 749 | l = (max + min) / 2; 750 | if (max == min) { 751 | h = s = 0; 752 | } else { 753 | d = max - min; 754 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 755 | switch (max) { 756 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 757 | case g: h = (b - r) / d + 2; break; 758 | case b: h = (r - g) / d + 4; break 759 | } 760 | h /= 6; 761 | } 762 | // h = Math.floor(h * 360) 763 | // s = Math.floor(s * 100) 764 | // l = Math.floor(l * 100) 765 | return { 766 | h: h, 767 | s: s, 768 | l: rgb2lum(r,g,b), 769 | }; 770 | } 771 | 772 | function hueGroup(hue, segs) { 773 | var seg = 1/segs, 774 | haf = seg/2; 775 | 776 | if (hue >= 1 - haf || hue <= haf) 777 | return 0; 778 | 779 | for (var i = 1; i < segs; i++) { 780 | var mid = i*seg; 781 | if (hue >= mid - haf && hue <= mid + haf) 782 | return i; 783 | } 784 | } 785 | 786 | function satGroup(sat) { 787 | return sat; 788 | } 789 | 790 | function lumGroup(lum) { 791 | return lum; 792 | } 793 | 794 | function typeOf(val) { 795 | return Object.prototype.toString.call(val).slice(8,-1); 796 | } 797 | 798 | var sort = isArrSortStable() ? Array.prototype.sort : stableSort; 799 | 800 | // must be used via stableSort.call(arr, fn) 801 | function stableSort(fn) { 802 | var type = typeOf(this[0]); 803 | 804 | if (type == "Number" || type == "String") { 805 | var ord = {}, len = this.length, val; 806 | 807 | for (var i = 0; i < len; i++) { 808 | val = this[i]; 809 | if (ord[val] || ord[val] === 0) continue; 810 | ord[val] = i; 811 | } 812 | 813 | return this.sort(function(a,b) { 814 | return fn(a,b) || ord[a] - ord[b]; 815 | }); 816 | } 817 | else { 818 | var ord = this.map(function(v){return v}); 819 | 820 | return this.sort(function(a,b) { 821 | return fn(a,b) || ord.indexOf(a) - ord.indexOf(b); 822 | }); 823 | } 824 | } 825 | 826 | // test if js engine's Array#sort implementation is stable 827 | function isArrSortStable() { 828 | var str = "abcdefghijklmnopqrstuvwxyz"; 829 | 830 | return "xyzvwtursopqmnklhijfgdeabc" == str.split("").sort(function(a,b) { 831 | return ~~(str.indexOf(b)/2.3) - ~~(str.indexOf(a)/2.3); 832 | }).join(""); 833 | } 834 | 835 | // returns uniform pixel data from various img 836 | // TODO?: if array is passed, createimagedata, createlement canvas? take a pxlen? 837 | function getImageData(img, width) { 838 | var can, ctx, imgd, buf8, buf32, height; 839 | 840 | switch (typeOf(img)) { 841 | case "HTMLImageElement": 842 | can = document.createElement("canvas"); 843 | can.width = img.naturalWidth; 844 | can.height = img.naturalHeight; 845 | ctx = can.getContext("2d"); 846 | ctx.drawImage(img,0,0); 847 | case "Canvas": 848 | case "HTMLCanvasElement": 849 | can = can || img; 850 | ctx = ctx || can.getContext("2d"); 851 | case "CanvasRenderingContext2D": 852 | ctx = ctx || img; 853 | can = can || ctx.canvas; 854 | imgd = ctx.getImageData(0, 0, can.width, can.height); 855 | case "ImageData": 856 | imgd = imgd || img; 857 | width = imgd.width; 858 | if (typeOf(imgd.data) == "CanvasPixelArray") 859 | buf8 = new Uint8Array(imgd.data); 860 | else 861 | buf8 = imgd.data; 862 | case "Array": 863 | case "CanvasPixelArray": 864 | buf8 = buf8 || new Uint8Array(img); 865 | case "Uint8Array": 866 | case "Uint8ClampedArray": 867 | buf8 = buf8 || img; 868 | buf32 = new Uint32Array(buf8.buffer); 869 | case "Uint32Array": 870 | buf32 = buf32 || img; 871 | buf8 = buf8 || new Uint8Array(buf32.buffer); 872 | width = width || buf32.length; 873 | height = buf32.length / width; 874 | } 875 | 876 | return { 877 | can: can, 878 | ctx: ctx, 879 | imgd: imgd, 880 | buf8: buf8, 881 | buf32: buf32, 882 | width: width, 883 | height: height, 884 | }; 885 | } 886 | 887 | // partitions a rect of wid x hgt into 888 | // array of bboxes of w0 x h0 (or less) 889 | function makeBoxes(wid, hgt, w0, h0) { 890 | var wnum = ~~(wid/w0), wrem = wid%w0, 891 | hnum = ~~(hgt/h0), hrem = hgt%h0, 892 | xend = wid-wrem, yend = hgt-hrem; 893 | 894 | var bxs = []; 895 | for (var y = 0; y < hgt; y += h0) 896 | for (var x = 0; x < wid; x += w0) 897 | bxs.push({x:x, y:y, w:(x==xend?wrem:w0), h:(y==yend?hrem:h0)}); 898 | 899 | return bxs; 900 | } 901 | 902 | // iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent 903 | function iterBox(bbox, wid, fn) { 904 | var b = bbox, 905 | i0 = b.y * wid + b.x, 906 | i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1), 907 | cnt = 0, incr = wid - b.w + 1, i = i0; 908 | 909 | do { 910 | fn.call(this, i); 911 | i += (++cnt % b.w == 0) ? incr : 1; 912 | } while (i <= i1); 913 | } 914 | 915 | // returns array of hash keys sorted by their values 916 | function sortedHashKeys(obj, desc) { 917 | var keys = []; 918 | 919 | for (var key in obj) 920 | keys.push(key); 921 | 922 | return sort.call(keys, function(a,b) { 923 | return desc ? obj[b] - obj[a] : obj[a] - obj[b]; 924 | }); 925 | } 926 | 927 | // expose 928 | this.RgbQuant = RgbQuant; 929 | 930 | // expose to commonJS 931 | if (typeof module !== 'undefined' && module.exports) { 932 | module.exports = RgbQuant; 933 | } 934 | 935 | }).call(this); -------------------------------------------------------------------------------- /src/vendor/pixi-viewport.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("pixi.js")):"function"==typeof define&&define.amd?define(["exports","pixi.js"],e):e((t=t||self).Viewport={},t.PIXI)}(this,(function(t,e){"use strict";class i{constructor(t){this.viewport=t,this.touches=[],this.addListeners()}addListeners(){this.viewport.interactive=!0,this.viewport.forceHitArea||(this.viewport.hitArea=new e.Rectangle(0,0,this.viewport.worldWidth,this.viewport.worldHeight)),this.viewport.on("pointerdown",this.down,this),this.viewport.on("pointermove",this.move,this),this.viewport.on("pointerup",this.up,this),this.viewport.on("pointerupoutside",this.up,this),this.viewport.on("pointercancel",this.up,this),this.viewport.on("pointerout",this.up,this),this.wheelFunction=t=>this.handleWheel(t),this.viewport.options.divWheel.addEventListener("wheel",this.wheelFunction,{passive:this.viewport.options.passiveWheel}),this.isMouseDown=!1}destroy(){this.viewport.options.divWheel.removeEventListener("wheel",this.wheelFunction)}down(t){if(this.viewport.pause||!this.viewport.worldVisible)return;if("mouse"===t.data.pointerType?this.isMouseDown=!0:this.get(t.data.pointerId)||this.touches.push({id:t.data.pointerId,last:null}),1===this.count()){this.last=t.data.global.clone();const e=this.viewport.plugins.get("decelerate"),i=this.viewport.plugins.get("bounce");e&&e.isActive()||i&&i.isActive()?this.clickedAvailable=!1:this.clickedAvailable=!0}else this.clickedAvailable=!1;this.viewport.plugins.down(t)&&this.viewport.options.stopPropagation&&t.stopPropagation()}checkThreshold(t){return Math.abs(t)>=this.viewport.threshold}move(t){if(this.viewport.pause||!this.viewport.worldVisible)return;const e=this.viewport.plugins.move(t);if(this.clickedAvailable){const e=t.data.global.x-this.last.x,i=t.data.global.y-this.last.y;(this.checkThreshold(e)||this.checkThreshold(i))&&(this.clickedAvailable=!1)}e&&this.viewport.options.stopPropagation&&t.stopPropagation()}up(t){if(this.viewport.pause||!this.viewport.worldVisible)return;"mouse"===t.data.pointerType&&(this.isMouseDown=!1),"mouse"!==t.data.pointerType&&this.remove(t.data.pointerId);const e=this.viewport.plugins.up(t);this.clickedAvailable&&0===this.count()&&(this.viewport.emit("clicked",{event:t,screen:this.last,world:this.viewport.toWorld(this.last),viewport:this}),this.clickedAvailable=!1),e&&this.viewport.options.stopPropagation&&t.stopPropagation()}getPointerPosition(t){let i=new e.Point;return this.viewport.options.interaction?this.viewport.options.interaction.mapPositionToPoint(i,t.clientX,t.clientY):(i.x=t.clientX,i.y=t.clientY),i}handleWheel(t){if(this.viewport.pause||!this.viewport.worldVisible)return;const e=this.viewport.toLocal(this.getPointerPosition(t));if(this.viewport.left<=e.x&&e.x<=this.viewport.right&&this.viewport.top<=e.y&&e.y<=this.viewport.bottom){this.viewport.plugins.wheel(t)&&!this.viewport.options.passiveWheel&&t.preventDefault()}}pause(){this.touches=[],this.isMouseDown=!1}get(t){for(let e of this.touches)if(e.id===t)return e;return null}remove(t){for(let e=0;e{t.includes(e.code)&&(this.keyIsPressed=!0)}),parent.addEventListener("keyup",e=>{t.includes(e.code)&&(this.keyIsPressed=!1)})}mouseButtons(t){this.mouse=t&&"all"!==t?[-1!==t.indexOf("left"),-1!==t.indexOf("middle"),-1!==t.indexOf("right")]:[!0,!0,!0]}parseUnderflow(){const t=this.options.underflow.toLowerCase();"center"===t?(this.underflowX=0,this.underflowY=0):(this.underflowX=-1!==t.indexOf("left")?-1:-1!==t.indexOf("right")?1:0,this.underflowY=-1!==t.indexOf("top")?-1:-1!==t.indexOf("bottom")?1:0)}checkButtons(t){const e="mouse"===t.data.pointerType,i=this.parent.input.count();return!(!(1===i||i>1&&!this.parent.plugins.get("pinch"))||e&&!this.mouse[t.data.button])}checkKeyPress(t){return!!(!this.options.keyToPress||this.keyIsPressed||this.options.ignoreKeyToPressOnTouch&&"touch"===t.data.pointerType)}down(t){if(!this.paused&&this.options.pressDrag)return this.checkButtons(t)&&this.checkKeyPress(t)?(this.last={x:t.data.global.x,y:t.data.global.y},this.current=t.data.pointerId,!0):void(this.last=null)}get active(){return this.moved}move(t){if(!this.paused&&this.options.pressDrag&&this.last&&this.current===t.data.pointerId){const i=t.data.global.x,s=t.data.global.y,n=this.parent.input.count();if(1===n||n>1&&!this.parent.plugins.get("pinch")){const n=i-this.last.x,h=s-this.last.y;if(this.moved||this.xDirection&&this.parent.input.checkThreshold(n)||this.yDirection&&this.parent.input.checkThreshold(h)){const n={x:i,y:s};return this.xDirection&&(this.parent.x+=(n.x-this.last.x)*this.options.factor),this.yDirection&&(this.parent.y+=(n.y-this.last.y)*this.options.factor),this.last=n,this.moved||this.parent.emit("drag-start",{event:t,screen:new e.Point(this.last.x,this.last.y),world:this.parent.toWorld(new e.Point(this.last.x,this.last.y)),viewport:this.parent}),this.moved=!0,this.parent.emit("moved",{viewport:this.parent,type:"drag"}),!0}}else this.moved=!1}}up(t){if(this.paused)return;const i=this.parent.input.touches;if(1===i.length){const t=i[0];return t.last&&(this.last={x:t.last.x,y:t.last.y},this.current=t.id),this.moved=!1,!0}if(this.last&&this.moved){const i=new e.Point(this.last.x,this.last.y);return this.parent.emit("drag-end",{event:t,screen:i,world:this.parent.toWorld(i),viewport:this.parent}),this.last=null,this.moved=!1,!0}}wheel(t){if(!this.paused&&this.options.wheel){if(!this.parent.plugins.get("wheel"))return this.xDirection&&(this.parent.x+=t.deltaX*this.options.wheelScroll*this.reverse),this.yDirection&&(this.parent.y+=t.deltaY*this.options.wheelScroll*this.reverse),this.options.clampWheel&&this.clamp(),this.parent.emit("wheel-scroll",this.parent),this.parent.emit("moved",{viewport:this.parent,type:"wheel"}),this.parent.options.passiveWheel||t.preventDefault(),!0}}resume(){this.last=null,this.paused=!1}clamp(){const t=this.parent.plugins.get("decelerate")||{};if("y"!==this.options.clampWheel)if(this.parent.screenWorldWidththis.parent.worldWidth&&(this.parent.x=-this.parent.worldWidth*this.parent.scale.x+this.parent.screenWidth,t.x=0);if("x"!==this.options.clampWheel)if(this.parent.screenWorldHeightthis.parent.worldHeight&&(this.parent.y=-this.parent.worldHeight*this.parent.scale.y+this.parent.screenHeight,t.y=0)}}const a={noDrag:!1,percent:1,center:null};class p extends h{constructor(t,e={}){super(t),this.options=Object.assign({},a,e)}down(){if(this.parent.input.count()>=2)return this.active=!0,!0}move(t){if(this.paused||!this.active)return;const e=t.data.global.x,i=t.data.global.y,s=this.parent.input.touches;if(s.length>=2){const n=s[0],h=s[1],o=n.last&&h.last?Math.sqrt(Math.pow(h.last.x-n.last.x,2)+Math.pow(h.last.y-n.last.y,2)):null;if(n.id===t.data.pointerId?n.last={x:e,y:i,data:t.data}:h.id===t.data.pointerId&&(h.last={x:e,y:i,data:t.data}),o){let t;const e={x:n.last.x+(h.last.x-n.last.x)/2,y:n.last.y+(h.last.y-n.last.y)/2};this.options.center||(t=this.parent.toLocal(e));let i=Math.sqrt(Math.pow(h.last.x-n.last.x,2)+Math.pow(h.last.y-n.last.y,2));i=0===i?i=1e-10:i;const s=(1-o/i)*this.options.percent*this.parent.scale.x;this.parent.scale.x+=s,this.parent.scale.y+=s,this.parent.emit("zoomed",{viewport:this.parent,type:"pinch"});const r=this.parent.plugins.get("clamp-zoom");if(r&&r.clamp(),this.options.center)this.parent.moveCenter(this.options.center);else{const i=this.parent.toGlobal(t);this.parent.x+=e.x-i.x,this.parent.y+=e.y-i.y,this.parent.emit("moved",{viewport:this.parent,type:"pinch"})}!this.options.noDrag&&this.lastCenter&&(this.parent.x+=e.x-this.lastCenter.x,this.parent.y+=e.y-this.lastCenter.y,this.parent.emit("moved",{viewport:this.parent,type:"pinch"})),this.lastCenter=e,this.moved=!0}else this.pinching||(this.parent.emit("pinch-start",this.parent),this.pinching=!0);return!0}}up(){if(this.pinching&&this.parent.input.touches.length<=1)return this.active=!1,this.lastCenter=null,this.pinching=!1,this.moved=!1,this.parent.emit("pinch-end",this.parent),!0}}const l={left:!1,right:!1,top:!1,bottom:!1,direction:null,underflow:"center"};class c extends h{constructor(t,e={}){super(t),this.options=Object.assign({},l,e),this.options.direction&&(this.options.left="x"===this.options.direction||"all"===this.options.direction||null,this.options.right="x"===this.options.direction||"all"===this.options.direction||null,this.options.top="y"===this.options.direction||"all"===this.options.direction||null,this.options.bottom="y"===this.options.direction||"all"===this.options.direction||null),this.parseUnderflow(),this.last={x:null,y:null,scaleX:null,scaleY:null},this.update()}parseUnderflow(){const t=this.options.underflow.toLowerCase();"none"===t?this.noUnderflow=!0:"center"===t?(this.underflowX=this.underflowY=0,this.noUnderflow=!1):(this.underflowX=-1!==t.indexOf("left")?-1:-1!==t.indexOf("right")?1:0,this.underflowY=-1!==t.indexOf("top")?-1:-1!==t.indexOf("bottom")?1:0,this.noUnderflow=!1)}move(){return this.update(),!1}update(){if(this.paused)return;if(this.parent.x===this.last.x&&this.parent.y===this.last.y&&this.parent.scale.x===this.last.scaleX&&this.parent.scale.y===this.last.scaleY)return;const t={x:this.parent.x,y:this.parent.y},e=this.parent.plugins.decelerate||{};if(null!==this.options.left||null!==this.options.right){let i=!1;if(this.parent.screenWorldWidth(!0===this.options.right?this.parent.worldWidth:this.options.right)&&(this.parent.x=-(!0===this.options.right?this.parent.worldWidth:this.options.right)*this.parent.scale.x+this.parent.screenWidth,e.x=0,i=!0);i&&this.parent.emit("moved",{viewport:this.parent,original:t,type:"clamp-x"})}if(null!==this.options.top||null!==this.options.bottom){let i=!1;if(this.parent.screenWorldHeight(!0===this.options.bottom?this.parent.worldHeight:this.options.bottom)&&(this.parent.y=-(!0===this.options.bottom?this.parent.worldHeight:this.options.bottom)*this.parent.scale.y+this.parent.screenHeight,e.y=0,i=!0);i&&this.parent.emit("moved",{viewport:this.parent,original:t,type:"clamp-y"})}this.last.x=this.parent.x,this.last.y=this.parent.y,this.last.scaleX=this.parent.scale.x,this.last.scaleY=this.parent.scale.y}reset(){this.update()}}const d={minWidth:null,minHeight:null,maxWidth:null,maxHeight:null,minScale:null,maxScale:null};class u extends h{constructor(t,e={}){super(t),this.options=Object.assign({},d,e),this.clamp()}resize(){this.clamp()}clamp(){if(!this.paused)if(this.options.minWidth||this.options.minHeight||this.options.maxWidth||this.options.maxHeight){let t=this.parent.worldScreenWidth,e=this.parent.worldScreenHeight;if(null!==this.options.minWidth&&tthis.options.maxWidth){const i=this.parent.scale.x;this.parent.fitWidth(this.options.maxWidth,!1,!1,!0),this.parent.scale.y*=this.parent.scale.x/i,t=this.parent.worldScreenWidth,e=this.parent.worldScreenHeight,this.parent.emit("zoomed",{viewport:this.parent,type:"clamp-zoom"})}if(null!==this.options.minHeight&&ethis.options.maxHeight){const t=this.parent.scale.y;this.parent.fitHeight(this.options.maxHeight,!1,!1,!0),this.parent.scale.x*=this.parent.scale.y/t,this.parent.emit("zoomed",{viewport:this.parent,type:"clamp-zoom"})}}else{let t=this.parent.scale.x;null!==this.options.minScale&&tthis.options.maxScale&&(t=this.options.maxScale),t!==this.parent.scale.x&&(this.parent.scale.set(t),this.parent.emit("zoomed",{viewport:this.parent,type:"clamp-zoom"}))}}reset(){this.clamp()}}const g={friction:.95,bounce:.8,minSpeed:.01};class m extends h{constructor(t,e={}){super(t),this.options=Object.assign({},g,e),this.saved=[],this.reset(),this.parent.on("moved",t=>this.moved(t))}destroy(){this.parent}down(){this.saved=[],this.x=this.y=!1}isActive(){return this.x||this.y}move(){if(this.paused)return;const t=this.parent.input.count();(1===t||t>1&&!this.parent.plugins.get("pinch"))&&(this.saved.push({x:this.parent.x,y:this.parent.y,time:performance.now()}),this.saved.length>60&&this.saved.splice(0,30))}moved(t){if(this.saved.length){const e=this.saved[this.saved.length-1];"clamp-x"===t.type?e.x===t.original.x&&(e.x=this.parent.x):"clamp-y"===t.type&&e.y===t.original.y&&(e.y=this.parent.y)}}up(){if(0===this.parent.input.count()&&this.saved.length){const t=performance.now();for(let e of this.saved)if(e.time>=t-100){const i=t-e.time;this.x=(this.parent.x-e.x)/i,this.y=(this.parent.y-e.y)/i,this.percentChangeX=this.percentChangeY=this.options.friction;break}}}activate(t){void 0!==(t=t||{}).x&&(this.x=t.x,this.percentChangeX=this.options.friction),void 0!==t.y&&(this.y=t.y,this.percentChangeY=this.options.friction)}update(t){if(this.paused)return;let e;this.x&&(this.parent.x+=this.x*t,this.x*=this.percentChangeX,Math.abs(this.x)=this.options.time?(this.parent.x=e.end,this.toX=null,this.parent.emit("bounce-x-end",this.parent)):this.parent.x=this.ease(e.time,e.start,e.delta,this.options.time)}if(this.toY){const e=this.toY;e.time+=t,this.parent.emit("moved",{viewport:this.parent,type:"bounce-y"}),e.time>=this.options.time?(this.parent.y=e.end,this.toY=null,this.parent.emit("bounce-y-end",this.parent)):this.parent.y=this.ease(e.time,e.start,e.delta,this.options.time)}}}calcUnderflowX(){let t;switch(this.underflowX){case-1:t=0;break;case 1:t=this.parent.screenWidth-this.parent.screenWorldWidth;break;default:t=(this.parent.screenWidth-this.parent.screenWorldWidth)/2}return t}calcUnderflowY(){let t;switch(this.underflowY){case-1:t=0;break;case 1:t=this.parent.screenHeight-this.parent.screenWorldHeight;break;default:t=(this.parent.screenHeight-this.parent.screenWorldHeight)/2}return t}oob(){const t=this.options.bounceBox;if(t){const i=void 0===t.x?0:t.x,s=void 0===t.y?0:t.y,n=void 0===t.width?this.parent.worldWidth:t.width,h=void 0===t.height?this.parent.worldHeight:t.height;return{left:this.parent.leftn,top:this.parent.toph,topLeft:new e.Point(i*this.parent.scale.x,s*this.parent.scale.y),bottomRight:new e.Point(n*this.parent.scale.x-this.parent.screenWidth,h*this.parent.scale.y-this.parent.screenHeight)}}return{left:this.parent.left<0,right:this.parent.right>this.parent.worldWidth,top:this.parent.top<0,bottom:this.parent.bottom>this.parent.worldHeight,topLeft:new e.Point(0,0),bottomRight:new e.Point(this.parent.worldWidth*this.parent.scale.x-this.parent.screenWidth,this.parent.worldHeight*this.parent.scale.y-this.parent.screenHeight)}}bounce(){if(this.paused)return;let t,e=this.parent.plugins.get("decelerate");e&&(e.x||e.y)&&(e.x&&e.percentChangeX===e.options.friction||e.y&&e.percentChangeY===e.options.friction)&&(t=this.oob(),(t.left&&this.left||t.right&&this.right)&&(e.percentChangeX=this.options.friction),(t.top&&this.top||t.bottom&&this.bottom)&&(e.percentChangeY=this.options.friction));const i=this.parent.plugins.get("drag")||{},s=this.parent.plugins.get("pinch")||{};if(e=e||{},!(i.active||s.active||this.toX&&this.toY||e.x&&e.y)){t=t||this.oob();const i=t.topLeft,s=t.bottomRight;if(!this.toX&&!e.x){let e=null;t.left&&this.left?e=this.parent.screenWorldWidththis.options.time)i=!0,s=this.startX+this.deltaX,n=this.startY+this.deltaY;else{const t=this.ease(e.time,0,1,this.options.time);s=this.startX+this.deltaX*t,n=this.startY+this.deltaY*t}this.options.topLeft?this.parent.moveCorner(s,n):this.parent.moveCenter(s,n),this.parent.emit("moved",{viewport:this.parent,type:"snap"}),i&&(this.options.removeOnComplete&&this.parent.plugins.remove("snap"),this.parent.emit("snap-end",this.parent),this.snapping=null)}else{const t=this.options.topLeft?this.parent.corner:this.parent.center;t.x===this.x&&t.y===this.y||this.snapStart()}}}const H={width:0,height:0,time:1e3,ease:"easeInOutSine",center:null,interrupt:!0,removeOnComplete:!1,removeOnInterrupts:!1,forceStart:!1,noMove:!1};class M extends h{constructor(t,e={}){super(t),this.options=Object.assign({},H,e),this.ease=y(this.options.ease),this.options.width>0&&(this.xScale=t.screenWidth/this.options.width),this.options.height>0&&(this.yScale=t.screenHeight/this.options.height),this.xIndependent=!!this.xScale,this.yIndependent=!!this.yScale,this.xScale=this.xIndependent?this.xScale:this.yScale,this.yScale=this.yIndependent?this.yScale:this.xScale,0===this.options.time?(t.container.scale.x=this.xScale,t.container.scale.y=this.yScale,this.options.removeOnComplete&&this.parent.plugins.remove("snap-zoom")):e.forceStart&&this.createSnapping()}createSnapping(){const t=this.parent.scale;this.snapping={time:0,startX:t.x,startY:t.y,deltaX:this.xScale-t.x,deltaY:this.yScale-t.y},this.parent.emit("snap-zoom-start",this.parent)}resize(){this.snapping=null,this.options.width>0&&(this.xScale=this.parent.screenWidth/this.options.width),this.options.height>0&&(this.yScale=this.parent.screenHeight/this.options.height),this.xScale=this.xIndependent?this.xScale:this.yScale,this.yScale=this.yIndependent?this.yScale:this.xScale}wheel(){this.options.removeOnInterrupt&&this.parent.plugins.remove("snap-zoom")}down(){this.options.removeOnInterrupt?this.parent.plugins.remove("snap-zoom"):this.options.interrupt&&(this.snapping=null)}update(t){if(this.paused)return;if(this.options.interrupt&&0!==this.parent.input.count())return;let e;if(this.options.center||this.options.noMove||(e=this.parent.center),this.snapping){if(this.snapping){const i=this.snapping;if(i.time+=t,i.time>=this.options.time)this.parent.scale.set(this.xScale,this.yScale),this.options.removeOnComplete&&this.parent.plugins.remove("snap-zoom"),this.parent.emit("snap-zoom-end",this.parent),this.snapping=null;else{const t=this.snapping;this.parent.scale.x=this.ease(t.time,t.startX,t.deltaX,this.options.time),this.parent.scale.y=this.ease(t.time,t.startY,t.deltaY,this.options.time)}const s=this.parent.plugins.get("clamp-zoom");s&&s.clamp(),this.options.noMove||(this.options.center?this.parent.moveCenter(this.options.center):this.parent.moveCenter(e))}}else this.parent.scale.x===this.xScale&&this.parent.scale.y===this.yScale||this.createSnapping()}resume(){this.snapping=null,super.resume()}}const S={speed:0,acceleration:null,radius:null};class O extends h{constructor(t,e,i={}){super(t),this.target=e,this.options=Object.assign({},S,i),this.velocity={x:0,y:0}}update(t){if(this.paused)return;const e=this.parent.center;let i=this.target.x,s=this.target.y;if(this.options.radius){if(!(Math.sqrt(Math.pow(this.target.y-e.y,2)+Math.pow(this.target.x-e.x,2))>this.options.radius))return;{const t=Math.atan2(this.target.y-e.y,this.target.x-e.x);i=this.target.x-Math.cos(t)*this.options.radius,s=this.target.y-Math.sin(t)*this.options.radius}}const n=i-e.x,h=s-e.y;if(n||h)if(this.options.speed)if(this.options.acceleration){const o=Math.atan2(s-e.y,i-e.x),r=Math.sqrt(Math.pow(n,2)+Math.pow(h,2));if(r){const a=(Math.pow(this.velocity.x,2)+Math.pow(this.velocity.y,2))/(2*this.options.acceleration);this.velocity=r>a?{x:Math.min(this.velocity.x+this.options.acceleration*t,this.options.speed),y:Math.min(this.velocity.y+this.options.acceleration*t,this.options.speed)}:{x:Math.max(this.velocity.x-this.options.acceleration*this.options.speed,0),y:Math.max(this.velocity.y-this.options.acceleration*this.options.speed,0)};const p=Math.cos(o)*this.velocity.x,l=Math.sin(o)*this.velocity.y,c=Math.abs(p)>Math.abs(n)?i:e.x+p,d=Math.abs(l)>Math.abs(h)?s:e.y+l;this.parent.moveCenter(c,d),this.parent.emit("moved",{viewport:this.parent,type:"follow"})}}else{const t=Math.atan2(s-e.y,i-e.x),o=Math.cos(t)*this.options.speed,r=Math.sin(t)*this.options.speed,a=Math.abs(o)>Math.abs(n)?i:e.x+o,p=Math.abs(r)>Math.abs(h)?s:e.y+r;this.parent.moveCenter(a,p),this.parent.emit("moved",{viewport:this.parent,type:"follow"})}else this.parent.moveCenter(i,s),this.parent.emit("moved",{viewport:this.parent,type:"follow"})}}const z={percent:.1,smooth:!1,interrupt:!0,reverse:!1,center:null,lineHeight:20};class I extends h{constructor(t,e={}){super(t),this.options=Object.assign({},z,e)}down(){this.options.interrupt&&(this.smoothing=null)}update(){if(this.smoothing){const t=this.smoothingCenter,e=this.smoothing;let i;this.options.center||(i=this.parent.toLocal(t)),this.parent.scale.x+=e.x,this.parent.scale.y+=e.y,this.parent.emit("zoomed",{viewport:this.parent,type:"wheel"});const s=this.parent.plugins.get("clamp-zoom");if(s&&s.clamp(),this.options.center)this.parent.moveCenter(this.options.center);else{const e=this.parent.toGlobal(i);this.parent.x+=t.x-e.x,this.parent.y+=t.y-e.y}this.parent.emit("moved",{viewport:this.parent,type:"wheel"}),this.smoothingCount++,this.smoothingCount>=this.options.smooth&&(this.smoothing=null)}}wheel(t){if(this.paused)return;let e=this.parent.input.getPointerPosition(t);const i=(this.options.reverse?-1:1)*-t.deltaY*(t.deltaMode?this.options.lineHeight:1)/500,s=Math.pow(2,(1+this.options.percent)*i);if(this.options.smooth){const t={x:this.smoothing?this.smoothing.x*(this.options.smooth-this.smoothingCount):0,y:this.smoothing?this.smoothing.y*(this.options.smooth-this.smoothingCount):0};this.smoothing={x:((this.parent.scale.x+t.x)*s-this.parent.scale.x)/this.options.smooth,y:((this.parent.scale.y+t.y)*s-this.parent.scale.y)/this.options.smooth},this.smoothingCount=0,this.smoothingCenter=e}else{let t;this.options.center||(t=this.parent.toLocal(e)),this.parent.scale.x*=s,this.parent.scale.y*=s,this.parent.emit("zoomed",{viewport:this.parent,type:"wheel"});const i=this.parent.plugins.get("clamp-zoom");if(i&&i.clamp(),this.options.center)this.parent.moveCenter(this.options.center);else{const i=this.parent.toGlobal(t);this.parent.x+=e.x-i.x,this.parent.y+=e.y-i.y}}return this.parent.emit("moved",{viewport:this.parent,type:"wheel"}),this.parent.emit("wheel",{wheel:{dx:t.deltaX,dy:t.deltaY,dz:t.deltaZ},event:t,viewport:this.parent}),!this.parent.options.passiveWheel||void 0}}const C={radius:null,distance:null,top:null,bottom:null,left:null,right:null,speed:8,reverse:!1,noDecelerate:!1,linear:!1,allowButtons:!1};class k extends h{constructor(t,e={}){super(t),this.options=Object.assign({},C,e),this.reverse=this.options.reverse?1:-1,this.radiusSquared=Math.pow(this.options.radius,2),this.resize()}resize(){const t=this.options.distance;null!==t?(this.left=t,this.top=t,this.right=this.parent.worldScreenWidth-t,this.bottom=this.parent.worldScreenHeight-t):this.radius||(this.left=this.options.left,this.top=this.options.top,this.right=null===this.options.right?null:this.parent.worldScreenWidth-this.options.right,this.bottom=null===this.options.bottom?null:this.parent.worldScreenHeight-this.options.bottom)}down(){this.options.allowButtons||(this.horizontal=this.vertical=null)}move(t){if("mouse"!==t.data.pointerType&&1!==t.data.identifier||!this.options.allowButtons&&0!==t.data.buttons)return;const e=t.data.global.x,i=t.data.global.y;if(this.radiusSquared){const t=this.parent.toScreen(this.parent.center);if(Math.pow(t.x-e,2)+Math.pow(t.y-i,2)>=this.radiusSquared){const s=Math.atan2(t.y-i,t.x-e);this.options.linear?(this.horizontal=Math.round(Math.cos(s))*this.options.speed*this.reverse*.06,this.vertical=Math.round(Math.sin(s))*this.options.speed*this.reverse*.06):(this.horizontal=Math.cos(s)*this.options.speed*this.reverse*.06,this.vertical=Math.sin(s)*this.options.speed*this.reverse*.06)}else this.horizontal&&this.decelerateHorizontal(),this.vertical&&this.decelerateVertical(),this.horizontal=this.vertical=0}else null!==this.left&&ethis.right?this.horizontal=-1*this.reverse*this.options.speed*.06:(this.decelerateHorizontal(),this.horizontal=0),null!==this.top&&ithis.bottom?this.vertical=-1*this.reverse*this.options.speed*.06:(this.decelerateVertical(),this.vertical=0)}decelerateHorizontal(){const t=this.parent.plugins.get("decelerate");this.horizontal&&t&&!this.options.noDecelerate&&t.activate({x:this.horizontal*this.options.speed*this.reverse/(1e3/60)})}decelerateVertical(){const t=this.parent.plugins.get("decelerate");this.vertical&&t&&!this.options.noDecelerate&&t.activate({y:this.vertical*this.options.speed*this.reverse/(1e3/60)})}up(){this.horizontal&&this.decelerateHorizontal(),this.vertical&&this.decelerateVertical(),this.horizontal=this.vertical=null}update(){if(!this.paused&&(this.horizontal||this.vertical)){const t=this.parent.center;this.horizontal&&(t.x+=this.horizontal*this.options.speed),this.vertical&&(t.y+=this.vertical*this.options.speed),this.parent.moveCenter(t),this.parent.emit("moved",{viewport:this.parent,type:"mouse-edges"})}}}const P={screenWidth:window.innerWidth,screenHeight:window.innerHeight,worldWidth:null,worldHeight:null,threshold:5,passiveWheel:!0,stopPropagation:!1,forceHitArea:null,noTicker:!1,interaction:null,disableOnContextMenu:!1};class X extends e.Container{constructor(t={}){if(super(),this.options=Object.assign({},P,t),t.ticker)this.options.ticker=t.ticker;else{let i;const s=e;i=parseInt(/^(\d+)\./.exec(e.VERSION)[1])<5?s.ticker.shared:s.Ticker.shared,this.options.ticker=t.ticker||i}this.screenWidth=this.options.screenWidth,this.screenHeight=this.options.screenHeight,this._worldWidth=this.options.worldWidth,this._worldHeight=this.options.worldHeight,this.forceHitArea=this.options.forceHitArea,this.threshold=this.options.threshold,this.options.divWheel=this.options.divWheel||document.body,this.options.disableOnContextMenu&&(this.options.divWheel.oncontextmenu=t=>t.preventDefault()),this.options.noTicker||(this.tickerFunction=()=>this.update(this.options.ticker.elapsedMS),this.options.ticker.add(this.tickerFunction)),this.input=new i(this),this.plugins=new n(this)}destroy(t){this.options.noTicker||this.options.ticker.remove(this.tickerFunction),this.input.destroy(),super.destroy(t)}update(t){this.pause||(this.plugins.update(t),this.lastViewport&&(this.lastViewport.x!==this.x||this.lastViewport.y!==this.y?this.moving=!0:this.moving&&(this.emit("moved-end",this),this.moving=!1),this.lastViewport.scaleX!==this.scale.x||this.lastViewport.scaleY!==this.scale.y?this.zooming=!0:this.zooming&&(this.emit("zoomed-end",this),this.zooming=!1)),this.forceHitArea||(this._hitAreaDefault=new e.Rectangle(this.left,this.top,this.worldScreenWidth,this.worldScreenHeight),this.hitArea=this._hitAreaDefault),this._dirty=this._dirty||!this.lastViewport||this.lastViewport.x!==this.x||this.lastViewport.y!==this.y||this.lastViewport.scaleX!==this.scale.x||this.lastViewport.scaleY!==this.scale.y,this.lastViewport={x:this.x,y:this.y,scaleX:this.scale.x,scaleY:this.scale.y},this.emit("frame-end",this))}resize(t=window.innerWidth,e=window.innerHeight,i,s){this.screenWidth=t,this.screenHeight=e,void 0!==i&&(this._worldWidth=i),void 0!==s&&(this._worldHeight=s),this.plugins.resize()}get worldWidth(){return this._worldWidth?this._worldWidth:this.width/this.scale.x}set worldWidth(t){this._worldWidth=t,this.plugins.resize()}get worldHeight(){return this._worldHeight?this._worldHeight:this.height/this.scale.y}set worldHeight(t){this._worldHeight=t,this.plugins.resize()}getVisibleBounds(){return new e.Rectangle(this.left,this.top,this.worldScreenWidth,this.worldScreenHeight)}toWorld(t,i){return 2===arguments.length?this.toLocal(new e.Point(t,i)):this.toLocal(t)}toScreen(t,i){return 2===arguments.length?this.toGlobal(new e.Point(t,i)):this.toGlobal(t)}get worldScreenWidth(){return this.screenWidth/this.scale.x}get worldScreenHeight(){return this.screenHeight/this.scale.y}get screenWorldWidth(){return this.worldWidth*this.scale.x}get screenWorldHeight(){return this.worldHeight*this.scale.y}get center(){return new e.Point(this.worldScreenWidth/2-this.x/this.scale.x,this.worldScreenHeight/2-this.y/this.scale.y)}set center(t){this.moveCenter(t)}moveCenter(){let t,e;return isNaN(arguments[0])?(t=arguments[0].x,e=arguments[0].y):(t=arguments[0],e=arguments[1]),this.position.set((this.worldScreenWidth/2-t)*this.scale.x,(this.worldScreenHeight/2-e)*this.scale.y),this.plugins.reset(),this.dirty=!0,this}get corner(){return new e.Point(-this.x/this.scale.x,-this.y/this.scale.y)}set corner(t){this.moveCorner(t)}moveCorner(t,e){return 1===arguments.length?this.position.set(-t.x*this.scale.x,-t.y*this.scale.y):this.position.set(-t*this.scale.x,-e*this.scale.y),this.plugins.reset(),this}fitWidth(t,e,i=!0,s){let n;e&&(n=this.center),this.scale.x=this.screenWidth/t,i&&(this.scale.y=this.scale.x);const h=this.plugins.get("clamp-zoom");return!s&&h&&h.clamp(),e&&this.moveCenter(n),this}fitHeight(t,e,i=!0,s){let n;e&&(n=this.center),this.scale.y=this.screenHeight/t,i&&(this.scale.x=this.scale.y);const h=this.plugins.get("clamp-zoom");return!s&&h&&h.clamp(),e&&this.moveCenter(n),this}fitWorld(t){let e;t&&(e=this.center),this.scale.x=this.screenWidth/this.worldWidth,this.scale.y=this.screenHeight/this.worldHeight,this.scale.xthis.worldWidth,top:this.top<0,bottom:this.bottom>this._worldHeight,cornerPoint:new e.Point(this.worldWidth*this.scale.x-this.screenWidth,this.worldHeight*this.scale.y-this.screenHeight)}}get right(){return-this.x/this.scale.x+this.worldScreenWidth}set right(t){this.x=-t*this.scale.x+this.screenWidth,this.plugins.reset()}get left(){return-this.x/this.scale.x}set left(t){this.x=-t*this.scale.x,this.plugins.reset()}get top(){return-this.y/this.scale.y}set top(t){this.y=-t*this.scale.y,this.plugins.reset()}get bottom(){return-this.y/this.scale.y+this.worldScreenHeight}set bottom(t){this.y=-t*this.scale.y+this.screenHeight,this.plugins.reset()}get dirty(){return this._dirty}set dirty(t){this._dirty=t}get forceHitArea(){return this._forceHitArea}set forceHitArea(t){t?(this._forceHitArea=t,this.hitArea=t):(this._forceHitArea=null,this.hitArea=new e.Rectangle(0,0,this.worldWidth,this.worldHeight))}drag(t){return this.plugins.add("drag",new r(this,t)),this}clamp(t){return this.plugins.add("clamp",new c(this,t)),this}decelerate(t){return this.plugins.add("decelerate",new m(this,t)),this}bounce(t){return this.plugins.add("bounce",new v(this,t)),this}pinch(t){return this.plugins.add("pinch",new p(this,t)),this}snap(t,e,i){return this.plugins.add("snap",new W(this,t,e,i)),this}follow(t,e){return this.plugins.add("follow",new O(this,t,e)),this}wheel(t){return this.plugins.add("wheel",new I(this,t)),this}clampZoom(t){return this.plugins.add("clamp-zoom",new u(this,t)),this}mouseEdges(t){return this.plugins.add("mouse-edges",new k(this,t)),this}get pause(){return this._pause}set pause(t){this._pause=t,this.lastViewport=null,this.moving=!1,this.zooming=!1,t&&this.input.pause()}ensureVisible(t,e,i,s,n){n&&(i>this.worldScreenWidth||s>this.worldScreenHeight)&&(this.fit(!0,i,s),this.emit("zoomed",{viewport:this,type:"ensureVisible"}));let h=!1;tthis.right&&(this.right=t+i,h=!0),ethis.bottom&&(this.bottom=e+s,h=!0),h&&this.emit("moved",{viewport:this,type:"ensureVisible"})}}t.Plugin=h,t.Viewport=X,Object.defineProperty(t,"__esModule",{value:!0})})); 2 | //# sourceMappingURL=viewport.js.map 3 | --------------------------------------------------------------------------------