├── tests ├── package.json ├── new_canvas.spec.js ├── playwright.config.js ├── brush_from_selection.spec.js ├── rotate_brush.spec.js ├── line_tool.spec.js └── fill_tool.spec.js ├── Dockerfile ├── .gitignore ├── _img ├── iff.png ├── mui.png ├── os3.png ├── os4.png ├── ui.png ├── base.fla ├── frame.png ├── brushes.png ├── favicon.ico ├── favicon.png ├── floppy.png ├── icon192.png ├── icon48.png ├── icon512.png ├── amigatick.png ├── cursors │ ├── cross.png │ ├── pipette.png │ ├── rotatene.png │ ├── rotatenw.png │ ├── rotatese.png │ └── rotatesw.png ├── dpaint-about.png ├── dpaint-logo.png ├── patterns │ ├── dots.png │ ├── grid.png │ ├── plus.png │ ├── cross.png │ ├── cross2.png │ ├── gradient.png │ ├── lines_hor.png │ ├── lines_ver.png │ ├── longgrid.png │ └── lines_diag.png ├── brushes │ ├── brush1.png │ ├── brush2.png │ └── brush3.png ├── line.svg ├── add.svg ├── pencil.svg ├── hamburger.svg ├── swap.svg ├── check.svg ├── caret.svg ├── square.svg ├── eraser.svg ├── sidebar.svg ├── split.svg ├── pause.svg ├── circle.svg ├── play.svg ├── text.svg ├── download.svg ├── folder.svg ├── trashcan.svg ├── warning.svg ├── redo.svg ├── undo.svg ├── square_fill.svg ├── image.svg ├── fullscreen.svg ├── nofill.svg ├── nofill-white.svg ├── lock_open.svg ├── circle_fill.svg ├── gradient.svg ├── spray.svg ├── smudge.svg ├── zoomout.svg ├── disk.svg ├── rotate1.svg ├── eye.svg ├── palette.svg ├── lock_closed.svg ├── poly.svg ├── link.svg ├── layers.svg ├── zoom.svg ├── cycle.svg ├── editcolor.svg ├── hand.svg ├── select.svg ├── layers_mask.svg ├── fill.svg ├── pixelgrid.svg ├── magicwand.svg ├── pipette_white.svg ├── gif.svg ├── psd.svg ├── jpeg.svg ├── flask.svg ├── stamp.svg └── png.svg ├── _font ├── topaz-8.ttf ├── amiga-topaz.otf └── licence.txt ├── docs ├── _img │ ├── adf.gif │ ├── uae.gif │ ├── lasso.gif │ ├── dither.gif │ ├── eagle1.gif │ ├── gradient.gif │ ├── reduce.gif │ ├── steffest.png │ ├── stencil.gif │ ├── icon-preview.gif │ ├── scripted_fx.png │ └── warning-icon.svg ├── docs.txt ├── contact.html ├── why.html ├── changelog.txt ├── about.html ├── licenses.txt ├── faq.html └── licenses.html ├── docker-compose.yml ├── .idea └── .gitignore ├── README-docker.md ├── _style ├── _contentPanel.scss ├── _scrollbar.scss ├── _contextMenu.scss ├── _visualAids.scss ├── _var.scss ├── _statusbar.scss ├── _uae.scss ├── _gallery.scss ├── main.scss ├── _mobile.scss ├── _range.scss ├── _fileBrowser.scss ├── _ditherEditor.scss ├── _forms.scss ├── _paletteList.scss ├── _cursor.scss └── _menu.scss ├── _data └── palettes │ ├── MSX.json │ ├── CGA.json │ ├── Amstrad-CPC.json │ ├── Atari-2600-PAL.json │ ├── TED-Plus4-C16.json │ └── Atari-2600-NTSC.json ├── _script ├── ui │ ├── components │ │ ├── textOutputDialog.js │ │ ├── contextMenu.js │ │ ├── optionDialog.js │ │ ├── syntaxEdit.js │ │ ├── gridOverlay.js │ │ ├── resampleDialog.js │ │ └── paletteList.js │ ├── ui.js │ ├── statusbar.js │ ├── contentpanel.js │ └── cursor.js ├── util │ ├── textUtils.js │ ├── crc32.js │ ├── eventbus.js │ └── animator.js ├── alchemy │ ├── offset.js │ ├── dots.js │ ├── lines.js │ ├── web.js │ ├── displace.js │ ├── mediansmoove.js │ ├── speckles.js │ ├── glow.js │ └── texture.js ├── userSettings.js ├── paintTools │ └── spray.js ├── host │ └── host.js └── fileformats │ └── detect.js ├── index.html ├── manifest.json ├── license.txt ├── package.json ├── scrap └── dev.js ├── dev.html └── TODO.txt /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | COPY . /usr/share/nginx/html 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /scrap 3 | /gallery/ 4 | /dist/ 5 | .parcel-cache -------------------------------------------------------------------------------- /_img/iff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/iff.png -------------------------------------------------------------------------------- /_img/mui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/mui.png -------------------------------------------------------------------------------- /_img/os3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/os3.png -------------------------------------------------------------------------------- /_img/os4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/os4.png -------------------------------------------------------------------------------- /_img/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/ui.png -------------------------------------------------------------------------------- /_img/base.fla: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/base.fla -------------------------------------------------------------------------------- /_img/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/frame.png -------------------------------------------------------------------------------- /_font/topaz-8.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_font/topaz-8.ttf -------------------------------------------------------------------------------- /_img/brushes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/brushes.png -------------------------------------------------------------------------------- /_img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/favicon.ico -------------------------------------------------------------------------------- /_img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/favicon.png -------------------------------------------------------------------------------- /_img/floppy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/floppy.png -------------------------------------------------------------------------------- /_img/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/icon192.png -------------------------------------------------------------------------------- /_img/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/icon48.png -------------------------------------------------------------------------------- /_img/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/icon512.png -------------------------------------------------------------------------------- /docs/_img/adf.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/adf.gif -------------------------------------------------------------------------------- /docs/_img/uae.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/uae.gif -------------------------------------------------------------------------------- /_img/amigatick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/amigatick.png -------------------------------------------------------------------------------- /docs/_img/lasso.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/lasso.gif -------------------------------------------------------------------------------- /_font/amiga-topaz.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_font/amiga-topaz.otf -------------------------------------------------------------------------------- /_img/cursors/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/cursors/cross.png -------------------------------------------------------------------------------- /_img/dpaint-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/dpaint-about.png -------------------------------------------------------------------------------- /_img/dpaint-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/dpaint-logo.png -------------------------------------------------------------------------------- /_img/patterns/dots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/dots.png -------------------------------------------------------------------------------- /_img/patterns/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/grid.png -------------------------------------------------------------------------------- /_img/patterns/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/plus.png -------------------------------------------------------------------------------- /docs/_img/dither.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/dither.gif -------------------------------------------------------------------------------- /docs/_img/eagle1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/eagle1.gif -------------------------------------------------------------------------------- /docs/_img/gradient.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/gradient.gif -------------------------------------------------------------------------------- /docs/_img/reduce.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/reduce.gif -------------------------------------------------------------------------------- /docs/_img/steffest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/steffest.png -------------------------------------------------------------------------------- /docs/_img/stencil.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/stencil.gif -------------------------------------------------------------------------------- /_img/brushes/brush1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/brushes/brush1.png -------------------------------------------------------------------------------- /_img/brushes/brush2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/brushes/brush2.png -------------------------------------------------------------------------------- /_img/brushes/brush3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/brushes/brush3.png -------------------------------------------------------------------------------- /_img/cursors/pipette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/cursors/pipette.png -------------------------------------------------------------------------------- /_img/patterns/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/cross.png -------------------------------------------------------------------------------- /_img/patterns/cross2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/cross2.png -------------------------------------------------------------------------------- /_img/cursors/rotatene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/cursors/rotatene.png -------------------------------------------------------------------------------- /_img/cursors/rotatenw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/cursors/rotatenw.png -------------------------------------------------------------------------------- /_img/cursors/rotatese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/cursors/rotatese.png -------------------------------------------------------------------------------- /_img/cursors/rotatesw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/cursors/rotatesw.png -------------------------------------------------------------------------------- /_img/patterns/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/gradient.png -------------------------------------------------------------------------------- /_img/patterns/lines_hor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/lines_hor.png -------------------------------------------------------------------------------- /_img/patterns/lines_ver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/lines_ver.png -------------------------------------------------------------------------------- /_img/patterns/longgrid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/longgrid.png -------------------------------------------------------------------------------- /docs/_img/icon-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/icon-preview.gif -------------------------------------------------------------------------------- /docs/_img/scripted_fx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/docs/_img/scripted_fx.png -------------------------------------------------------------------------------- /_img/patterns/lines_diag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steffest/DPaint-js/HEAD/_img/patterns/lines_diag.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | web: 5 | build: . 6 | ports: 7 | - "8080:80" 8 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /README-docker.md: -------------------------------------------------------------------------------- 1 | # Using Docker to deploy DPaint-js 2 | Use Docker compose: 3 | ```docker compose up --build``` 4 | 5 | To run in detached/daemonized mode: 6 | ```docker compose up -d --build``` 7 | -------------------------------------------------------------------------------- /_style/_contentPanel.scss: -------------------------------------------------------------------------------- 1 | /* override the default sidebar panel style */ 2 | .contentpanel{ 3 | position: absolute; 4 | left: 250px; 5 | width: 300px; 6 | z-index: 300; 7 | } 8 | 9 | body.withcontentpanel{ 10 | .contentpanel{ 11 | display: block; 12 | } 13 | } -------------------------------------------------------------------------------- /_data/palettes/MSX.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "palette", 3 | "palette": [ 4 | "#000000", 5 | "#cacaca", 6 | "#ffffff", 7 | "#b75e51", 8 | "#d96459", 9 | "#fe877c", 10 | "#cac15e", 11 | "#ddce85", 12 | "#3ca042", 13 | "#40b64a", 14 | "#73ce7c", 15 | "#5955df", 16 | "#7e75f0", 17 | "#64daee", 18 | "#b565b3" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /_data/palettes/CGA.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "palette", 3 | "palette": [ 4 | "#000000", 5 | "#0000aa", 6 | "#00aa00", 7 | "#00aaaa", 8 | "#aa0000", 9 | "#aa00aa", 10 | "#aa5500", 11 | "#aaaaaa", 12 | "#555555", 13 | "#5555ff", 14 | "#55ff55", 15 | "#55ffff", 16 | "#ff5555", 17 | "#ff55ff", 18 | "#ffff55", 19 | "#ffffff" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /_img/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /_img/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /_img/pencil.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /_img/hamburger.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_img/swap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /_img/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /_img/caret.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /_img/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /_font/licence.txt: -------------------------------------------------------------------------------- 1 | The FontStruction “Amiga Topaz” 2 | (https://fontstruct.com/fontstructions/show/675155) by Patrick H. Lauke is 3 | licensed under a Creative Commons Attribution license 4 | (http://creativecommons.org/licenses/by/3.0/). 5 | 6 | Derived from the original Topaz-8 Amiga font, included with Workbench 1.x by Bob Burns 7 | 8 | ------- 9 | Topaz-8 TTF Font 10 | by Danny Amor 11 | Freeware 12 | https://www.fontsaddict.com/font/topaz-8.html 13 | 14 | Derived from the original Topaz-8 Amiga font, included with Workbench 2.x/3.x by Peter J Cherna 15 | -------------------------------------------------------------------------------- /_img/eraser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /_img/sidebar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /_script/ui/components/textOutputDialog.js: -------------------------------------------------------------------------------- 1 | import $ from "../../util/dom.js"; 2 | 3 | let TextOutputDialog = function() { 4 | let me = {}; 5 | 6 | me.render = function (container,modal,data) { 7 | container.innerHTML = ""; 8 | let panel = $(".panel",{parent: container}); 9 | 10 | let textarea = $("textarea",{value:data}); 11 | textarea.onkeydown = function(e){ 12 | e.stopPropagation(); 13 | } 14 | panel.appendChild(textarea); 15 | 16 | } 17 | return me; 18 | }(); 19 | 20 | export default TextOutputDialog; -------------------------------------------------------------------------------- /docs/docs.txt: -------------------------------------------------------------------------------- 1 | Some observations: 2 | 3 | Copy/Paste 4 | 5 | With Chrome you can copy an image as file from the filesystem and paste it in DPaint. 6 | With Firefox this doesn't work. 7 | 8 | On OSX - Copy/Paste from Photoshop keeps transparency (if the image is in RGB color mode) - on Window it doesn't 9 | 10 | 11 | Good read on ClipBoard Access: 12 | https://web.dev/async-clipboard/ 13 | 14 | for Amibase: https://web.dev/async-clipboard/#permissions-policy-integration 15 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /_img/split.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /_data/palettes/Amstrad-CPC.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "palette", 3 | "palette": [ 4 | "#040404", 5 | "#808080", 6 | "#ffffff", 7 | "#800000", 8 | "#ff0000", 9 | "#ff8080", 10 | "#ff7f00", 11 | "#ffff80", 12 | "#ffff00", 13 | "#808000", 14 | "#008000", 15 | "#01ff00", 16 | "#80ff00", 17 | "#80ff80", 18 | "#01ff80", 19 | "#008080", 20 | "#01ffff", 21 | "#80ffff", 22 | "#0080ff", 23 | "#0000ff", 24 | "#00007f", 25 | "#7f00ff", 26 | "#8080ff", 27 | "#ff80ff", 28 | "#ff00ff", 29 | "#ff0080", 30 | "#800080" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /_style/_scrollbar.scss: -------------------------------------------------------------------------------- 1 | /* Firefox */ 2 | * { 3 | scrollbar-width: auto; 4 | scrollbar-color: #8c8c8c #454545; 5 | } 6 | 7 | /* Chrome, Edge, and Safari */ 8 | *::-webkit-scrollbar { 9 | width: 12px; 10 | height: 12px; 11 | } 12 | 13 | *::-webkit-scrollbar-track { 14 | background: #000000; 15 | } 16 | 17 | *::-webkit-scrollbar-thumb { 18 | background-color: #4d4d4d; 19 | border-radius: 10px; 20 | border: 2px solid #2b2b2b; 21 | } 22 | 23 | *::-webkit-scrollbar-thumb:hover{ 24 | background-color: #626262; 25 | } 26 | 27 | ::-webkit-scrollbar-corner { 28 | background: #000000; 29 | } -------------------------------------------------------------------------------- /_img/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/contact.html: -------------------------------------------------------------------------------- 1 |

Contact

2 | 3 |

4 | 5 |
6 | 7 | If you have any questions, comments, or suggestions,
8 | please feel free to open an issue in GitHub or contact me at 9 | 10 |

11 | dev@stef.be  •  12 | @steffest  •  13 | Steffest#4438 14 | 15 |

16 | 17 |

18 | -------------------------------------------------------------------------------- /_img/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /_script/util/textUtils.js: -------------------------------------------------------------------------------- 1 | export function DuplicateName(name, layers) { 2 | const existingNames = new Set(layers.map(layer => layer.name)); 3 | const nameExists = (name) => existingNames.has(name); 4 | 5 | let baseName = name; 6 | 7 | const duplicateIndex = name.indexOf(" duplicate"); 8 | if (duplicateIndex !== -1) baseName = name.substring(0, duplicateIndex); 9 | 10 | let newName = baseName + " duplicate"; 11 | if (!nameExists(newName)) return newName; 12 | 13 | let counter = 2; 14 | do { 15 | newName = `${baseName} duplicate ${counter}`; 16 | counter++; 17 | } while (nameExists(newName)); 18 | 19 | return newName; 20 | } -------------------------------------------------------------------------------- /_img/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /_img/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /_img/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /_img/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /_img/trashcan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /_img/warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /_img/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /_img/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /_img/square_fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /_style/_contextMenu.scss: -------------------------------------------------------------------------------- 1 | .contextmenu{ 2 | position: absolute; 3 | z-index: 999; 4 | border: 1px solid black; 5 | background-color: $panel-background-color; 6 | box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.5); 7 | opacity: 0; 8 | display: none; 9 | pointer-events: none; 10 | color: $menu-text-color; 11 | 12 | &.active{ 13 | opacity: 1; 14 | display: block; 15 | pointer-events: all; 16 | } 17 | 18 | .contextmenuitem{ 19 | display: block; 20 | padding: 0 24px 0 10px; 21 | font-size: 13px; 22 | line-height: 23px; 23 | white-space: nowrap; 24 | position: relative; 25 | 26 | &:hover{ 27 | background-color: $panel-background-active; 28 | cursor: pointer; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /_script/util/crc32.js: -------------------------------------------------------------------------------- 1 | let CRC32 = function(){ 2 | 3 | let me = {}; 4 | 5 | let crcTable = new Uint32Array(256); 6 | for (let n = 0; n < 256; n++){ 7 | let c = n; 8 | for (let k = 0; k < 8; k++){ 9 | if (c & 1){ 10 | c = 0xedb88320 ^ (c >>> 1); 11 | }else{ 12 | c = c >>> 1; 13 | } 14 | } 15 | crcTable[n] = c; 16 | } 17 | 18 | me.get= function(data){ 19 | let crc = 0xffffffff; 20 | for (let i = 0; i < data.length; i++){ 21 | crc = crcTable[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); 22 | } 23 | return crc ^ 0xffffffff; 24 | } 25 | 26 | return me; 27 | 28 | }() 29 | 30 | export default CRC32; -------------------------------------------------------------------------------- /_style/_visualAids.scss: -------------------------------------------------------------------------------- 1 | .visualaids{ 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | z-index: 1000; 6 | pointer-events: none; 7 | right: 0; 8 | bottom: 0; 9 | 10 | .grid{ 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | right: 0; 15 | bottom: 0; 16 | pointer-events: none; 17 | 18 | .line{ 19 | position: absolute; 20 | pointer-events: none; 21 | 22 | 23 | &.vertical{ 24 | top: 0; 25 | bottom: 0; 26 | border-left: 1px solid white; 27 | width: 1px; 28 | } 29 | 30 | &.horizontal{ 31 | left: 0; 32 | right: 0; 33 | border-top: 1px solid white; 34 | height: 1px; 35 | } 36 | } 37 | } 38 | 39 | 40 | } -------------------------------------------------------------------------------- /_img/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | -------------------------------------------------------------------------------- /_img/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /_style/_var.scss: -------------------------------------------------------------------------------- 1 | $background-color: #2B2B2B; 2 | $panel-background-color: #313335; 3 | $panel-background-active: #3C3F41; 4 | 5 | $button-background-dark: #282A2C; 6 | $button-background-medium: #2D2E30; 7 | 8 | $menu-text-color: #BBBBBB; 9 | $menu-text-color-high: #DDDDDD; 10 | $menu-text-color-dim: #8f8f8f; 11 | $dimmed-text-color: #adadad; 12 | 13 | $active-color: #b1e00f; 14 | $active-color-dim: #b2970e; 15 | 16 | $positive-background: #313c31; 17 | 18 | @mixin pixelated{ 19 | image-rendering: optimizeSpeed; 20 | image-rendering: optimize-contrast; 21 | image-rendering: -webkit-optimize-contrast; 22 | image-rendering: crisp-edges; 23 | image-rendering: -moz-crisp-edges; 24 | image-rendering: -o-crisp-edges; 25 | image-rendering: pixelated; 26 | -ms-interpolation-mode: nearest-neighbor; 27 | } -------------------------------------------------------------------------------- /docs/_img/warning-icon.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /_script/alchemy/offset.js: -------------------------------------------------------------------------------- 1 | let process = function(source,target){ 2 | let expose = { 3 | distanceX:{min:0,max:100,value:50}, 4 | distanceY:{min:0,max:100,value:50}, 5 | }; 6 | 7 | let w = source.width; 8 | let h = source.height; 9 | 10 | target.clearRect(0,0,target.canvas.width,target.canvas.height); 11 | 12 | let fx = expose.distanceX.value/100; 13 | let fy = expose.distanceY.value/100; 14 | 15 | let bw = Math.floor(w * fx); 16 | let bh = Math.floor(h * fy); 17 | 18 | let aw = w - bw; 19 | let ah = h - bh; 20 | 21 | target.drawImage(source,0,0,aw,ah,bw,bh,aw,ah); 22 | target.drawImage(source,aw,0,bw,ah,0,bh,bw,ah); 23 | target.drawImage(source,0,ah,aw,bh,bw,0,aw,bh); 24 | target.drawImage(source,aw,ah,bw,bh,0,0,bw,bh); 25 | 26 | } 27 | 28 | 29 | export default process; -------------------------------------------------------------------------------- /_img/nofill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /_img/nofill-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | DPaint JS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /_img/lock_open.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /_style/_statusbar.scss: -------------------------------------------------------------------------------- 1 | .statusbar{ 2 | position: absolute; 3 | left: 0; 4 | right: 0; 5 | bottom: 0; 6 | height: 20px; 7 | background-color: $panel-background-color; 8 | color: $menu-text-color; 9 | font-size: 12px; 10 | padding: 2px 0 0 10px; 11 | border-top: 1px solid black; 12 | overflow: hidden; 13 | display: flex; 14 | 15 | .tooltip{ 16 | b{ 17 | font-weight: normal; 18 | display: inline-block; 19 | border: 1px solid #9a9a9a; 20 | font-size: 10px; 21 | height: 14px;; 22 | padding: 0 3px; 23 | text-align: center; 24 | } 25 | 26 | } 27 | 28 | .permatip{ 29 | display: none; 30 | 31 | &.active{ 32 | border: 1px solid #ff610e; 33 | color: #ff610e; 34 | padding: 0 2px; 35 | display: block; 36 | margin-right: 6px; 37 | } 38 | } 39 | } 40 | 41 | body.presentation{ 42 | .statusbar{ 43 | display: none; 44 | } 45 | } -------------------------------------------------------------------------------- /_script/userSettings.js: -------------------------------------------------------------------------------- 1 | import {SETTING} from "./enum.js"; 2 | 3 | let UserSettings = (()=>{ 4 | let me = {}; 5 | 6 | const getDefaultSettings = ()=>{ 7 | let result = {}; 8 | result[SETTING.touchRotate] = true; 9 | return result; 10 | } 11 | 12 | let settings = getDefaultSettings(); 13 | let stored = localStorage.getItem("dp_settings"); 14 | if (stored){ 15 | try { 16 | settings = JSON.parse(stored); 17 | }catch (e) { 18 | console.error("Could not parse settings", e); 19 | settings = getDefaultSettings(); 20 | } 21 | } 22 | 23 | 24 | me.get = (key)=>{ 25 | return settings[key]; 26 | } 27 | 28 | me.set = (key,value)=>{ 29 | settings[key] = value; 30 | localStorage.setItem("dp_settings",JSON.stringify(settings)); 31 | } 32 | 33 | return me; 34 | 35 | })(); 36 | 37 | export default UserSettings; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dpaint.js", 3 | "icons": [ 4 | { 5 | "src": "_img/icon48.png", 6 | "sizes": "48x48", 7 | "type": "image/png", 8 | "density": 1.0 9 | }, 10 | { 11 | "src": "_img/icon192.png", 12 | "sizes": "192x192", 13 | "type": "image/png", 14 | "density": 1.0 15 | }, 16 | { 17 | "src": "_img/icon512.png", 18 | "sizes": "512x512", 19 | "type": "image/png", 20 | "density": 1.0, 21 | "purpose": "any maskable" 22 | } 23 | ], 24 | "start_url": "index.html", 25 | "display": "standalone", 26 | "orientation": "any", 27 | "background_color": "#282A2C", 28 | "theme_color": "#282A2C", 29 | "file_handlers": [ 30 | { 31 | "action": "/", 32 | "accept": { 33 | "image/jpeg": [".jpg", ".jpeg"], 34 | "image/png": [".png"], 35 | "image/iff": [".iff"] 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /_img/circle_fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /_img/gradient.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /_img/spray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /_img/smudge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /_img/zoomout.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /_script/ui/components/contextMenu.js: -------------------------------------------------------------------------------- 1 | import {$div} from "../../util/dom.js"; 2 | import Cursor from "../cursor.js"; 3 | import EventBus from "../../util/eventbus.js"; 4 | 5 | let ContextMenu = (()=>{ 6 | let menu; 7 | let me = {} 8 | 9 | me.show = (items)=>{ 10 | if (!menu){ 11 | menu = $div("contextmenu"); 12 | document.body.appendChild(menu); 13 | } 14 | 15 | menu.innerHTML = ""; 16 | items.forEach(item=>{ 17 | $div("contextmenuitem",item.label,menu,()=>{ 18 | if (item.command) EventBus.trigger(item.command); 19 | if (item.action) item.action(); 20 | me.hide(); 21 | }) 22 | }) 23 | 24 | let position = Cursor.getPosition(); 25 | menu.style.left = position.x + "px"; 26 | menu.style.top = position.y + "px"; 27 | menu.classList.add("active"); 28 | } 29 | 30 | me.hide = ()=>{ 31 | if (menu) menu.classList.remove("active"); 32 | } 33 | 34 | return me; 35 | })(); 36 | 37 | export default ContextMenu; -------------------------------------------------------------------------------- /_img/disk.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /_img/rotate1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /_img/eye.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_img/palette.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Steffest 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /_script/util/eventbus.js: -------------------------------------------------------------------------------- 1 | let EventBus = function(){ 2 | let me = {}; 3 | let handlers = {}; 4 | let active = true; 5 | let buffer = {}; 6 | 7 | me.hold = function(){ 8 | active = false; 9 | } 10 | me.release = function(){ 11 | let keys = Object.keys(buffer); 12 | console.log("releasing " + keys.length + " event(s)") 13 | keys.forEach(key=>{ 14 | me.trigger(key,buffer[key]); 15 | }); 16 | buffer = {}; 17 | active = true; 18 | } 19 | 20 | me.trigger = function(action,context){ 21 | if (!active){ 22 | buffer[action] = context; 23 | return; 24 | } 25 | let actionHandler = handlers[action]; 26 | if (actionHandler){ 27 | actionHandler.forEach(handler=>{ 28 | handler(context); 29 | }) 30 | } 31 | } 32 | 33 | me.on = function(action,handler){ 34 | handlers[action] = handlers[action] || []; 35 | let actionHandler = handlers[action]; 36 | actionHandler.push(handler); 37 | } 38 | 39 | return me; 40 | }(); 41 | 42 | export default EventBus; -------------------------------------------------------------------------------- /_img/lock_closed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 16 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /_img/poly.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DPaintJS", 3 | "version": "0.1.4", 4 | "description": "Webbased image editor with a focus on retro Amiga file formats in plain JavaScript", 5 | "repository": "https://github.com/steffest/dpaint-js", 6 | "author": "Steffest", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@parcel/packager-raw-url": "^2.12.0", 10 | "@parcel/transformer-webmanifest": "^2.12.0", 11 | "@playwright/test": "^1.57.0", 12 | "assert": "^2.0.0", 13 | "browserify-zlib": "^0.2.0", 14 | "buffer": "^5.5.0", 15 | "events": "^3.1.0", 16 | "http-server": "^14.1.1", 17 | "parcel": "^2.12.0", 18 | "playwright": "^1.57.0", 19 | "process": "^0.11.10", 20 | "stream-browserify": "^3.0.0", 21 | "util": "^0.12.3" 22 | }, 23 | "scripts": { 24 | "clean": "rm -rf dist && rm -rf .parcel-cache", 25 | "cleanwin": "del /q .\\.parcel-cache\\* && del /q .\\dist\\*", 26 | "build": "parcel build --public-url ./", 27 | "start": "npx http-server", 28 | "test": "playwright test --config ./tests/playwright.config.js" 29 | }, 30 | "source": "index.html" 31 | } 32 | -------------------------------------------------------------------------------- /scrap/dev.js: -------------------------------------------------------------------------------- 1 | import Modal, {DIALOG} from "../_script/ui/modal.js"; 2 | import UI from "../_script/ui/ui.js"; 3 | import Input from "../_script/ui/input.js"; 4 | 5 | let App = function(){ 6 | let me = {} 7 | let canvas; 8 | let canvasOut; 9 | 10 | me.init = function(){ 11 | Input.init(); 12 | canvas = document.createElement("canvas"); 13 | canvasOut = document.createElement("canvas"); 14 | canvasOut.width = canvas.width = 256; 15 | canvasOut.height = canvas.height = 256; 16 | 17 | document.body.appendChild(canvas); 18 | document.body.appendChild(canvasOut); 19 | let image = new Image(); 20 | image.onload = ()=>{ 21 | canvas.getContext("2d").drawImage(image,0,0); 22 | } 23 | image.src = "scrap/face1.png"; 24 | 25 | Modal.show(DIALOG.EFFECTS); 26 | } 27 | 28 | window.addEventListener('DOMContentLoaded', (event) => { 29 | me.init(); 30 | }); 31 | 32 | me.getTarget = ()=>{ 33 | return canvasOut.getContext("2d"); 34 | } 35 | 36 | me.getSource = ()=>{ 37 | return canvas; 38 | } 39 | 40 | 41 | 42 | 43 | return me; 44 | }(); 45 | 46 | export default App -------------------------------------------------------------------------------- /_script/ui/components/optionDialog.js: -------------------------------------------------------------------------------- 1 | import $,{$setTarget} from "../../util/dom.js"; 2 | import ImageFile from "../../image.js"; 3 | 4 | let OptionDialog = function() { 5 | let me = {}; 6 | 7 | me.render = function (container,modal,data) { 8 | container.innerHTML = ""; 9 | let panel = $(".optiondialog",{parent: container}); 10 | 11 | $setTarget(panel) 12 | //if (data.title) $("h3",data.title); 13 | if (data.text){ 14 | if (typeof data.text === "string") data.text=[data.text]; 15 | data.text.forEach(text=>{$("p",text);}); 16 | } 17 | 18 | if (data.buttons){ 19 | data.buttons.forEach(button=>{ 20 | $(".button.large",{onclick: ()=>{ 21 | if (button.onclick) button.onclick(); 22 | modal.hide() 23 | }},button.label); 24 | }) 25 | }else{ 26 | $(".buttons.relative", 27 | $(".button.ghost",{onclick: modal.hide},"Cancel"), 28 | $(".button.primary",{onclick: ()=>{modal.hide(); if (data.onOk) data.onOk()}},"OK") 29 | ); 30 | } 31 | } 32 | return me; 33 | }(); 34 | 35 | export default OptionDialog; -------------------------------------------------------------------------------- /_img/link.svg: -------------------------------------------------------------------------------- 1 | 3 | 12 | 13 | -------------------------------------------------------------------------------- /_img/layers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /_img/zoom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /_style/_uae.scss: -------------------------------------------------------------------------------- 1 | .uae{ 2 | position: absolute; 3 | left:0; 4 | top: 0; 5 | z-index: 1000; 6 | background-color: $button-background-dark; 7 | box-shadow: 0 0 20px 0px black; 8 | 9 | .caption{ 10 | height: 20px; 11 | color: $menu-text-color; 12 | border-bottom: 1px solid black; 13 | font-size: 12px; 14 | padding: 2px 2px 2px 24px; 15 | background-image: url("../_img/amigatick.png"); 16 | background-size: contain; 17 | background-repeat: no-repeat; 18 | background-position: 2px center; 19 | cursor: move; 20 | 21 | .close{ 22 | position: absolute; 23 | height: 20px; 24 | width: 20px; 25 | line-height: 20px; 26 | right: 0; 27 | top: 0; 28 | text-align: center; 29 | cursor: pointer; 30 | opacity: 0.7; 31 | 32 | &:hover{ 33 | opacity: 1; 34 | } 35 | } 36 | } 37 | 38 | .resizer{ 39 | position: absolute; 40 | width: 20px; 41 | height: 20px; 42 | right: 0; 43 | bottom: 0; 44 | border:10px solid transparent; 45 | border-bottom-color: #313335; 46 | border-right-color: #313335; 47 | cursor: nwse-resize; 48 | } 49 | 50 | iframe{ 51 | width: 100%; 52 | height: calc(100% - 21px); 53 | border: none; 54 | } 55 | 56 | &.dragging{ 57 | iframe{ 58 | pointer-events: none; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /_style/_gallery.scss: -------------------------------------------------------------------------------- 1 | .gallery{ 2 | 3 | .item{ 4 | margin: auto; 5 | width: 120px; 6 | font-size: 11px; 7 | color: $menu-text-color; 8 | margin-top: 10px; 9 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.7); 10 | transition: transform 0.2s, color 0.2s; 11 | 12 | &:hover{ 13 | cursor: pointer; 14 | transform: scale(1.05); 15 | 16 | .fileinfo{ 17 | background-color: #505050; 18 | } 19 | color: $menu-text-color-high; 20 | } 21 | } 22 | 23 | .thumb{ 24 | width: 120px; 25 | height: 70px; 26 | background-size: cover; 27 | background-position: center; 28 | } 29 | 30 | .fileinfo{ 31 | position: relative; 32 | padding: 4px; 33 | background-color: #404244; 34 | } 35 | 36 | .year{ 37 | position: absolute; 38 | right: 4px; 39 | top: 4px; 40 | color: $menu-text-color-dim; 41 | } 42 | 43 | .artist{ 44 | font-style: italic; 45 | } 46 | 47 | a.artist{ 48 | color: $menu-text-color-high; 49 | &:hover{ 50 | color: white; 51 | } 52 | } 53 | 54 | 55 | .section{ 56 | padding: 4px 8px; 57 | .title{ 58 | font-size: 13px; 59 | color: $menu-text-color; 60 | border-bottom: 1px solid $menu-text-color; 61 | margin: 8px 0; 62 | } 63 | 64 | .description{ 65 | font-size: 12px; 66 | color: $menu-text-color-dim; 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /_img/cycle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 17 | 18 | 19 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /_img/editcolor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tests/new_canvas.spec.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { test, expect } from '@playwright/test'; 3 | 4 | test('Create New Canvas', async ({ page }) => { 5 | await page.goto('/index.html'); 6 | await expect(page.locator('.panel.left .maincanvas')).toBeVisible(); 7 | 8 | const fileMenu = page.locator('.menuitem.main:text-matches("^File", "i")'); 9 | await fileMenu.click(); 10 | 11 | await expect(fileMenu).toHaveClass(/active/); 12 | const newItem = fileMenu.locator('.menuitem.sub a:text-matches("^New", "i")'); 13 | await expect(newItem).toBeVisible(); 14 | await newItem.click(); 15 | 16 | // Verify Canvas Dimensions 17 | const canvas = page.locator('.panel.left .maincanvas'); 18 | await expect(canvas).toHaveAttribute('width', '320'); 19 | await expect(canvas).toHaveAttribute('height', '256'); 20 | 21 | 22 | // verify the canvas is blank (transparent). 23 | const isBlank = await page.evaluate(() => { 24 | const canvas = /** @type {HTMLCanvasElement} */ (document.querySelector('.panel.left .maincanvas')); 25 | if (!canvas) return false; 26 | const ctx = canvas.getContext('2d'); 27 | if (!ctx) return false; 28 | const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; 29 | for (let i = 0; i < data.length; i += 4) { 30 | if (data[i + 3] !== 0) return false; // Alpha should be 0 31 | } 32 | return true; 33 | }); 34 | expect(isBlank).toBe(true); 35 | 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /docs/why.html: -------------------------------------------------------------------------------- 1 |

Why ?

2 | 3 | There are many many excellent paint programs out there.

4 | Next to the unavoidable PhotoShop there are many excellent pixel art tools like 5 | Asesprite, 6 | ProMotion NG, 7 | GrafX2,... 8 |
9 | And even brilliant browser based image editors like PhotoPea, 10 | Piskel or 11 | Pixelorama, ... 12 | 13 |

14 | 15 | So why make another one?

16 | The main reason is that I wanted a modern tool for creating Amiga Icons, a long gone ancient file format that no modern editor supports. 17 | (And that even the icon editors on the Amiga platform struggle with)
18 | Next to that I was missing some pixel-specific features that I knew from the good old Deluxe Paint days.

19 | 20 | DPaint.js was born: a tool that combines 3 things: 21 | 26 |

27 |

28 | 29 | -------------------------------------------------------------------------------- /_img/hand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /_img/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig, devices } from '@playwright/test'; 3 | 4 | /** 5 | * @see https://playwright.dev/docs/test-configuration 6 | */ 7 | export default defineConfig({ 8 | testDir: './', 9 | /* Run tests in files in parallel */ 10 | fullyParallel: true, 11 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 12 | forbidOnly: !!process.env.CI, 13 | /* Retry on CI only */ 14 | retries: process.env.CI ? 2 : 0, 15 | /* Opt out of parallel tests on CI. */ 16 | workers: process.env.CI ? 1 : undefined, 17 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 18 | reporter: 'line', 19 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 20 | use: { 21 | /* Base URL to use in actions like `await page.goto('/')`. */ 22 | baseURL: 'http://localhost:8080', 23 | 24 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 25 | trace: 'on-first-retry', 26 | }, 27 | 28 | /* Configure projects for major browsers */ 29 | projects: [ 30 | { 31 | name: 'chromium', 32 | use: { ...devices['Desktop Chrome'] }, 33 | } 34 | ], 35 | 36 | /* Run your local dev server before starting the tests */ 37 | webServer: { 38 | command: 'npm run start', 39 | url: 'http://localhost:8080', 40 | reuseExistingServer: !process.env.CI, 41 | cwd: ".." 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /_img/layers_mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 13 | 14 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/changelog.txt: -------------------------------------------------------------------------------- 1 | V 0.1.0 2 | - Initial release 3 | 4 | V 0.1.1 5 | - Bugfixes 6 | - Additional palettes 7 | - Duplicate Frame 8 | - ReOrder Frame 9 | - pixelated circle tool 10 | 11 | V 0.1.2 12 | - Resize image: option for smooth rescale or pixelated rescale 13 | - UAE window is now draggable and resizable 14 | - fill in gaps in draw tool on fast mouse moves 15 | - Additional palettes and pagination for long palettes 16 | - UI to change Alchemy script parameters 17 | - smudge tool 18 | - bugfixes for Amiga Color Icons 19 | 20 | V 0.1.3 21 | - color cycling support 22 | - HSV mode and other palette editing improvements 23 | - PBM support (Thanks Michael) 24 | - navigate palette and frames with arrow keys 25 | - load files from url and other url parameter commands 26 | - presentation mode 27 | - support for saving indexed color PNGs 28 | V 0.1.4 29 | - palette locking 30 | - spray tool 31 | - text tool 32 | - rotsprite and mapped-smooth rotation 33 | - Better touch screen support on Android 34 | - pressure sensitivity support for stylus 35 | - smear/blur tools 36 | V 0.1.5 37 | - Load/Save Brushes 38 | - support for 12 and 9 bit color depths (Amiga OCS / Atari ST) 39 | - animated GIF import/export 40 | - IFF ANIM import 41 | - Grid tool 42 | - Autosave current image in local storage 43 | - resizable sidebar panels 44 | - improved Save Dialog with options 45 | - Option to save as JPG (yeah... I caved) -------------------------------------------------------------------------------- /_script/ui/ui.js: -------------------------------------------------------------------------------- 1 | import Input from "./input.js"; 2 | import {$div} from "../util/dom.js"; 3 | import Menu from "./menu.js"; 4 | import Toolbar from "./toolbar.js"; 5 | import Editor from "./editor.js"; 6 | import Cursor from "./cursor.js"; 7 | import Sidepanel from "./sidepanel.js"; 8 | import Contentpanel from "./contentpanel.js"; 9 | import StatusBar from "./statusbar.js"; 10 | import PaletteList from "./components/paletteList.js"; 11 | import EventBus from "../util/eventbus.js"; 12 | import {COMMAND, EVENT} from "../enum.js"; 13 | 14 | let UI = function(){ 15 | let me = {} 16 | let container; 17 | 18 | me.init = function(){ 19 | container = $div("container"); 20 | document.body.appendChild(container); 21 | Cursor.init(); 22 | Input.init(); 23 | Menu.init(container); 24 | Toolbar.init(container); 25 | StatusBar.init(container); 26 | Sidepanel.init(container); 27 | Contentpanel.init(container); 28 | PaletteList.init(container); 29 | Editor.init(container); 30 | 31 | window.addEventListener("resize",()=>{ 32 | EventBus.trigger(EVENT.UIresize); 33 | },{passive:true}); 34 | } 35 | 36 | me.fuzzy = function(value){ 37 | if (container) container.classList.toggle("fuzzy",value) 38 | } 39 | 40 | 41 | me.getContainer = function(){ 42 | return container; 43 | } 44 | 45 | me.inPresentation = function(){ 46 | return document.body.classList.contains("presentation"); 47 | } 48 | 49 | EventBus.on(COMMAND.PRESENTATION,()=>{ 50 | document.body.classList.toggle("presentation"); 51 | }) 52 | 53 | return me; 54 | }(); 55 | 56 | export default UI; -------------------------------------------------------------------------------- /_script/ui/statusbar.js: -------------------------------------------------------------------------------- 1 | import {$div} from "../util/dom.js"; 2 | import EventBus from "../util/eventbus.js"; 3 | import {EVENT, COMMAND} from "../enum.js"; 4 | 5 | let StatusBar = (()=>{ 6 | let me = {}; 7 | let container; 8 | let toolTip; 9 | let permaTip; 10 | let permaTips = {} 11 | let overide = false; 12 | 13 | me.init = (parent)=>{ 14 | container = $div("statusbar","",parent); 15 | permaTip = $div("permatip","",container); 16 | toolTip = $div("tooltip","",container); 17 | } 18 | 19 | me.setToolTip = (text)=>{ 20 | if (overide) return; 21 | toolTip.innerHTML = text; 22 | } 23 | 24 | me.overideToolTip = (text)=>{ 25 | overide = true; 26 | toolTip.innerHTML = text; 27 | } 28 | 29 | EventBus.on(EVENT.penOnlyChanged, (active)=>{ 30 | permaTips.pen = active; 31 | setPermaTips(); 32 | }) 33 | 34 | EventBus.on(EVENT.paletteLockChanged, (active)=>{ 35 | permaTips.paletteLock = active; 36 | setPermaTips(); 37 | }) 38 | 39 | EventBus.on(COMMAND.RECORDINGSTART, ()=>{ 40 | permaTips.recording = true; 41 | setPermaTips(); 42 | }) 43 | 44 | EventBus.on(COMMAND.RECORDINGSTOP, ()=>{ 45 | permaTips.recording = false; 46 | setPermaTips(); 47 | }) 48 | 49 | function setPermaTips(){ 50 | permaTip.innerHTML = ""; 51 | if (permaTips.pen) permaTip.innerHTML += "Pen mode "; 52 | if (permaTips.paletteLock) permaTip.innerHTML += "Palette lock "; 53 | if (permaTips.recording) permaTip.innerHTML += "● Recording "; 54 | permaTip.classList.toggle("active",!!permaTip.innerHTML); 55 | } 56 | 57 | return me; 58 | })(); 59 | 60 | export default StatusBar; -------------------------------------------------------------------------------- /_style/main.scss: -------------------------------------------------------------------------------- 1 | @import "var"; 2 | 3 | html { 4 | box-sizing: border-box; 5 | overflow: hidden; 6 | } 7 | *, *:before, *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | body{ 12 | background-color: $background-color; 13 | font-family: sans-serif; 14 | font-size: 14px; 15 | height: 100%; 16 | margin: 0; 17 | padding: 1px; 18 | user-select: none; 19 | -moz-user-select: none; 20 | -webkit-user-select: none; 21 | -webkit-touch-callout: none; 22 | } 23 | 24 | .container{ 25 | position: relative; 26 | height: 100%; 27 | background-color: $background-color; 28 | 29 | &.fuzzy{ 30 | pointer-events: none; 31 | filter: blur(2px); 32 | } 33 | } 34 | 35 | h1.error{ 36 | text-align: center; 37 | color: white; 38 | font-size: 24px; 39 | font-weight: 100; 40 | margin: 50px auto; 41 | width: 50%; 42 | 43 | a{ 44 | color: white; 45 | } 46 | } 47 | 48 | .spinner{ 49 | position: absolute; 50 | top: 40%; 51 | left: 50%; 52 | margin-left: -25px; 53 | width: 50px; 54 | height: 50px; 55 | border: 5px solid rgba(255, 255, 255, 0.3); 56 | border-top: 5px solid white; 57 | border-radius: 50%; 58 | animation: spin 1s linear infinite; 59 | 60 | } 61 | 62 | 63 | 64 | @import "scrollbar"; 65 | @import "forms"; 66 | @import "range"; 67 | @import "menu"; 68 | @import "contextMenu"; 69 | @import "toolbar"; 70 | @import "statusbar"; 71 | @import "sidepanel"; 72 | @import "contentpanel"; 73 | @import "paletteList"; 74 | @import "editor"; 75 | @import "visualAids"; 76 | @import "selection"; 77 | @import "cursor"; 78 | @import "modal"; 79 | @import "fileBrowser"; 80 | @import "gallery"; 81 | @import "uae"; 82 | @import "mobile"; -------------------------------------------------------------------------------- /_script/alchemy/dots.js: -------------------------------------------------------------------------------- 1 | let process = function(source,target){ 2 | let expose = { 3 | dotRadius:{min:1,max:50,value:5}, 4 | radiusJitter:{min:0,max:50,value:5}, 5 | positionJitter:{min:0,max:100,value:50}, 6 | alpha:{min:0,max:100,value:10} 7 | }; 8 | 9 | let ctx=source.getContext("2d"); 10 | let w = source.width; 11 | let h = source.height; 12 | target.clearRect(0,0,target.canvas.width,target.canvas.height); 13 | target.drawImage(source,0,0); 14 | 15 | let data = ctx.getImageData(0,0,w,h); 16 | let d = data.data; 17 | 18 | function dot(){ 19 | let x = Math.floor(Math.random()*w); 20 | let y = Math.floor(Math.random()*h); 21 | let color = getColor(x,y); 22 | target.fillStyle = toRGBA(color,expose.alpha.value/100); 23 | splash(x,y,expose.dotRadius.value,expose.positionJitter.value,expose.radiusJitter.value); 24 | } 25 | 26 | function getColor(x,y){ 27 | let index = (y*w+x)*4; 28 | return [d[index],d[index+1],d[index+2]]; 29 | } 30 | 31 | for (let i = 0;i<500;i++){ 32 | dot(); 33 | } 34 | 35 | function splash(x,y,radius,jitter,jitterRadius){ 36 | jitterRadius =jitterRadius||jitter; 37 | for (let i = 0; i<20; i++){ 38 | let _x = x + Math.random()*jitter - jitter/2; 39 | let _y = y + Math.random()*jitter - jitter/2; 40 | let _r = radius + Math.random()*jitterRadius - jitterRadius/2; 41 | if (_r<1) _r=1; 42 | target.beginPath(); 43 | target.arc(_x, _y, _r, 0, 2 * Math.PI, false); 44 | target.fill(); 45 | } 46 | } 47 | 48 | function toRGBA(color,alpha){ 49 | return "rgba("+color[0]+","+color[1]+","+color[2]+","+alpha+")" 50 | } 51 | } 52 | 53 | 54 | export default process; -------------------------------------------------------------------------------- /_script/alchemy/lines.js: -------------------------------------------------------------------------------- 1 | let process = function(source,target){ 2 | let expose = { 3 | lineCount:{min:1,max:1000,value:500}, 4 | curve:{min:0,max:50,value:0}, 5 | horizontalShift:{min:5,max:100,value:35}, 6 | verticalShift:{min:-50,max:50,value:2}, 7 | }; 8 | 9 | let w = source.width; 10 | let h = source.height; 11 | target.clearRect(0,0,target.canvas.width,target.canvas.height); 12 | target.drawImage(source,0,0); 13 | 14 | function lineSet(color,alpha){ 15 | let x = Math.floor(Math.random()*w)-expose.horizontalShift.value/2+5; 16 | let y = Math.floor(Math.random()*h); 17 | 18 | let x_offset = expose.horizontalShift.value + Math.random()*10; 19 | let y_offset = -(expose.verticalShift.value) + Math.random()*2; 20 | 21 | if (expose.curve.value){ 22 | let hx = (w/2-x)/2; 23 | let hy = y/h; 24 | 25 | y_offset = Math.random()*hx * hy * expose.curve.value/10; 26 | y_offset = Math.round(y_offset); 27 | } 28 | 29 | let dist = 4; 30 | for (let i=0;i<4;i++){ 31 | let d = dist*i; 32 | line(x,y+d,x+x_offset,y+y_offset+d,toRGBA(color,alpha/(1<{ 44 | tick.now = newtime; 45 | tick.elapsed = tick.now - tick.then; 46 | 47 | if (tick.elapsed > tick.fpsInterval) { 48 | tick.then = tick.now - (tick.elapsed % tick.fpsInterval); 49 | tick.tickFunction(); 50 | } 51 | }) 52 | } 53 | } 54 | } 55 | 56 | function isTicking(){ 57 | for (let key in running){ 58 | if (running[key]) return true; 59 | } 60 | return false; 61 | } 62 | 63 | return me; 64 | }(); 65 | 66 | export default Animator; -------------------------------------------------------------------------------- /_script/alchemy/mediansmoove.js: -------------------------------------------------------------------------------- 1 | /* Median blurs an image while applying some sine smoothing waves 2 | * Experimental and cpu intensive - might crash from time to time 3 | * Steffest 4 | * */ 5 | 6 | let process = function(source,target){ 7 | let w = source.width; 8 | let h = source.height; 9 | target.clearRect(0,0,target.canvas.width,target.canvas.height); 10 | target.drawImage(source,0,0); 11 | 12 | function fakeBlur(strength){ 13 | // draws layers on top of each other with small 14 | target.globalAlpha = 0.1; 15 | for (let y = -strength; y <= strength; y += 2) { 16 | for (var x = -strength; x <= strength; x += 2) { 17 | // Apply layers 18 | target.drawImage(this.element, x, y); 19 | if (x>=0 && y>=0) { 20 | target.drawImage(this.element, -(x-1), -(y-1)); 21 | } 22 | } 23 | } 24 | target.globalAlpha = 1; 25 | } 26 | 27 | function smoove(color,alpha){ 28 | let x = Math.floor(Math.random()*w); 29 | let y = Math.floor(Math.random()*h); 30 | 31 | let x_len = 35 + Math.random()*10; 32 | let y_len = -2 + Math.random()*2; 33 | 34 | let dist = 4; 35 | for (let i=0;i<4;i++){ 36 | let d = dist*i; 37 | line(x,y+d,x+x_len,y+y_len+d,toRGBA(color,alpha/(1<About 2 | 3 | DPaint.js Logo 4 |

DPaint.js is a web based image editor modeled after the legendary Deluxe Paint.
5 | It's main purpose is to be used as a tool for creating pixel art, but it can also be used for general image editing.

6 | 7 |

It has a sweet spot for Amiga file formats: it can read and write all Amiga Icon formats and read/write Amiga IFF Images.
8 | You can even read/write files directly from ADF disk files and preview your work instantly in the "real" Deluxe Paint! (on an embedded emulated Amiga)

9 | 10 |

11 | It initially was made as a modern icon-editor for Amiga icons, but it grew into a fully featured image editor with 12 | 13 | 14 |

25 |

26 | 27 |

It runs in your browser, works on any system and works fine on touch-screen devices like iPads.
28 | It is written in 100% plain JavaScript and is completely open source.
29 | It's 100% free, no ads, no tracking, no accounts, no nothing.
30 | All processing is done in your browser, no data is sent to any server.

31 |

32 | 33 |
34 |

Small Demo Video of Amiga Spefific Features

35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /dev.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_img/fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /_script/paintTools/spray.js: -------------------------------------------------------------------------------- 1 | import Palette from "../ui/palette.js"; 2 | import ImageFile from "../image.js"; 3 | import EventBus from "../util/eventbus.js"; 4 | import {ANIMATION, EVENT} from "../enum.js"; 5 | import Animator from "../util/animator.js"; 6 | import ToolOptions from "../ui/components/toolOptions.js"; 7 | import Brush from "../ui/brush.js"; 8 | 9 | let Spray = (()=>{ 10 | 11 | let me={}; 12 | let currentData; 13 | let speed = 4; 14 | let size = 20; 15 | let useOpacity = true; 16 | 17 | me.start=function(touchData){ 18 | size = parseInt(ToolOptions.getSpread()) + 1; 19 | speed = Math.floor(ToolOptions.getStrength()*20) + 1; 20 | useOpacity = ToolOptions.usePressure(); 21 | if (!useOpacity) Brush.setPressure(1); 22 | 23 | let {x,y} = touchData; 24 | let color = touchData.button?Palette.getBackgroundColor():Palette.getDrawColor(); 25 | 26 | touchData.isSpraying = true; 27 | touchData.drawLayer = ImageFile.getActiveLayer(); 28 | touchData.drawLayer.draw(x,y,color,touchData); 29 | currentData = touchData; 30 | 31 | EventBus.trigger(EVENT.layerContentChanged); 32 | 33 | Animator.start(ANIMATION.SPRAY,()=>{ 34 | let {x,y} = currentData; 35 | let color = currentData.button?Palette.getBackgroundColor():Palette.getDrawColor(); 36 | 37 | for (let i = 0; i < speed; i++){ 38 | let angle = Math.random() * Math.PI * 2; 39 | let radius = Math.sqrt(Math.random()) * size; 40 | let _x = Math.round(x + radius * Math.cos(angle)); 41 | let _y = Math.round(y + radius * Math.sin(angle)); 42 | if (useOpacity) Brush.setPressure(Math.random()); 43 | currentData.drawLayer.draw(_x,_y,color,currentData); 44 | } 45 | 46 | EventBus.trigger(EVENT.layerContentChanged); 47 | },50); 48 | 49 | 50 | } 51 | 52 | me.stop=function(){ 53 | if (!currentData) return; 54 | currentData.isSpraying = false; 55 | Animator.stop(ANIMATION.SPRAY); 56 | } 57 | 58 | return me; 59 | })(); 60 | 61 | export default Spray; -------------------------------------------------------------------------------- /tests/brush_from_selection.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('Create Brush from Selection', async ({ page }) => { 4 | page.on('console', msg => { 5 | if (msg.type() === 'error') console.log(msg.text()); 6 | }); 7 | // 1. Navigate to the app 8 | await page.goto('/index.html'); 9 | await expect(page.locator('.panel.left .maincanvas')).toBeVisible(); 10 | 11 | // check if the about panel is visible and close it 12 | await page.locator('.caption > .button').click(); 13 | const aboutPanel = page.locator('.modalwindow'); 14 | if (await aboutPanel.isVisible()) { 15 | await aboutPanel.click(); 16 | } 17 | 18 | 19 | // 2. Select the "Select" tool 20 | const selectTool = page.locator('.button.icon.select'); 21 | await selectTool.click(); 22 | await expect(selectTool).toHaveClass(/active/); 23 | 24 | // 3. Make a selection on the canvas 25 | const canvas = page.locator('.panel.left .maincanvas'); 26 | const box = await canvas.boundingBox(); 27 | if (!box) throw new Error('Canvas bounding box not found'); 28 | 29 | await page.mouse.move(box.x + 50, box.y + 50); 30 | await page.mouse.down(); 31 | await page.mouse.move(box.x + 100, box.y + 100); 32 | await page.mouse.up(); 33 | 34 | // Debug: Log all menu items 35 | await page.evaluate(() => { 36 | const items = document.querySelectorAll('.menuitem.main'); 37 | items.forEach(item => console.log('Menu Item:', item.textContent, item.outerHTML)); 38 | }); 39 | 40 | // 4. Trigger "Brush -> From Selection" 41 | const brushMenu = page.locator('.menuitem.main:text-matches("^Brush", "i")'); 42 | await brushMenu.click(); 43 | await expect(brushMenu).toHaveClass(/active/); 44 | 45 | let fromSelectionItem = page.locator(".menuitem.sub a:text-matches('^From Selection', 'i')"); 46 | await expect(fromSelectionItem).toBeVisible(); 47 | await fromSelectionItem.click(); 48 | 49 | // 5. Verify that the "Draw" tool (Pencil) is now active 50 | const pencilTool = page.locator('.button.icon.pencil'); 51 | await expect(pencilTool).toHaveClass(/active/); 52 | 53 | // Verify "Select" tool is NOT active 54 | await expect(selectTool).not.toHaveClass(/active/); 55 | }); 56 | -------------------------------------------------------------------------------- /_data/palettes/Atari-2600-PAL.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "palette", 3 | "palette": [ 4 | "#000000", 5 | "#805800", 6 | "#445c00", 7 | "#703400", 8 | "#006414", 9 | "#700014", 10 | "#005c5c", 11 | "#70005c", 12 | "#003c70", 13 | "#580070", 14 | "#002070", 15 | "#3c0080", 16 | "#000088", 17 | "#404040", 18 | "#947020", 19 | "#5c7820", 20 | "#885020", 21 | "#208034", 22 | "#882034", 23 | "#207474", 24 | "#842074", 25 | "#1c5888", 26 | "#6c2088", 27 | "#1c3c88", 28 | "#542094", 29 | "#20209c", 30 | "#6c6c6c", 31 | "#a8843c", 32 | "#74903c", 33 | "#a0683c", 34 | "#3c9850", 35 | "#a03c50", 36 | "#3c8c8c", 37 | "#943c88", 38 | "#3874a0", 39 | "#803ca0", 40 | "#3858a0", 41 | "#6c3ca8", 42 | "#3c3cb0", 43 | "#909090", 44 | "#bc9c58", 45 | "#8cac58", 46 | "#b48458", 47 | "#58b06c", 48 | "#b4586c", 49 | "#58a4a4", 50 | "#a8589c", 51 | "#508cb4", 52 | "#9458b4", 53 | "#5074b4", 54 | "#8058bc", 55 | "#5858c0", 56 | "#b0b0b0", 57 | "#ccac70", 58 | "#a0c070", 59 | "#c89870", 60 | "#70c484", 61 | "#c87084", 62 | "#70b8b8", 63 | "#b470b0", 64 | "#68a4c8", 65 | "#a470c8", 66 | "#6888c8", 67 | "#9470cc", 68 | "#7070d0", 69 | "#c8c8c8", 70 | "#dcc084", 71 | "#b0d484", 72 | "#dcac84", 73 | "#84d89c", 74 | "#dc849c", 75 | "#84c8c8", 76 | "#c484c0", 77 | "#7cb8dc", 78 | "#b484dc", 79 | "#7ca0dc", 80 | "#a884dc", 81 | "#8484e0", 82 | "#dcdcdc", 83 | "#ecd09c", 84 | "#c0e89c", 85 | "#ecc09c", 86 | "#9ce8b4", 87 | "#ec9cb4", 88 | "#9cdcdc", 89 | "#d09cd0", 90 | "#90ccec", 91 | "#c49cec", 92 | "#90b4ec", 93 | "#b89cec", 94 | "#9c9cec", 95 | "#ececec", 96 | "#fce0b0", 97 | "#d4fcb0", 98 | "#fcd4b0", 99 | "#b0fcc8", 100 | "#fcb0c8", 101 | "#b0ecec", 102 | "#e0b0e0", 103 | "#a4e0fc", 104 | "#d4b0fc", 105 | "#a4c8fc", 106 | "#c8b0fc", 107 | "#b0b0fc" 108 | ] 109 | } 110 | -------------------------------------------------------------------------------- /_script/ui/contentpanel.js: -------------------------------------------------------------------------------- 1 | import {COMMAND, EVENT, SETTING} from "../enum.js"; 2 | import $, {$checkbox, $div} from "../util/dom.js"; 3 | import EventBus from "../util/eventbus.js"; 4 | import UserSettings from "../userSettings.js"; 5 | 6 | var ContentPanel = function(){ 7 | let me = {} 8 | let container; 9 | 10 | me.init = parent=>{ 11 | let w=175; 12 | let panel = $(".contentpanel",{ 13 | parent: parent, 14 | style: {width: w + "px"} 15 | }, 16 | container=$(".panelcontainer", 17 | $(".caption","Preferences",$(".close",{onClick:me.hide},"x")) 18 | ), 19 | $(".panelsizer",{ 20 | onDrag: (x)=>{ 21 | let _w = Math.max(w + x,120); 22 | panel.style.width = _w + "px"; 23 | //EventBus.trigger(EVENT.panelResized,_w); 24 | }, 25 | onDragStart: e=>{ 26 | w=panel.offsetWidth; 27 | } 28 | }) 29 | ); 30 | generate(); 31 | 32 | 33 | EventBus.on(EVENT.panelResized,(width=>{ 34 | panel.style.left = width + 75 + "px"; 35 | })); 36 | } 37 | 38 | me.show = (section)=>{ 39 | document.body.classList.add("withcontentpanel"); 40 | } 41 | 42 | me.hide = ()=>{ 43 | document.body.classList.remove("withcontentpanel"); 44 | } 45 | 46 | me.toggle = ()=>{ 47 | document.body.classList.toggle("withcontentpanel"); 48 | EventBus.trigger(EVENT.UIresize); 49 | } 50 | 51 | me.isVisible = ()=>{ 52 | return document.body.classList.contains("withcontentpanel"); 53 | } 54 | 55 | 56 | function generate(){ 57 | //container.innerHTML = ""; 58 | container.appendChild( 59 | $("section", 60 | $("h4","Touch"), 61 | $checkbox("Rotate on Pinch/zoom",null,"",(checked)=>{UserSettings.set(SETTING.touchRotate,checked)},UserSettings.get(SETTING.touchRotate)), 62 | ) 63 | ) 64 | } 65 | 66 | EventBus.on(COMMAND.PREFERENCES,me.toggle); 67 | 68 | return me; 69 | }() 70 | 71 | export default ContentPanel; -------------------------------------------------------------------------------- /_script/ui/components/syntaxEdit.js: -------------------------------------------------------------------------------- 1 | let SyntaxEdit = function(parent,onChange){ 2 | let me = {}; 3 | 4 | let regexes={ 5 | string1: {reg: /"(.*?)"/g, style:"string", wrap:'"'}, 6 | string2: {reg: /'(.*?)'/g, style:"string", wrap:"'"}, 7 | numbers: {reg: /(\b\d+\b)/g, style:"number"}, 8 | hex: {reg: /(\B#\w+)/g, style:"number"}, 9 | reserved: {reg: /\b(new|var|let|if|do|function|while|switch|for|foreach|in|continue|break)(?=[^\w])/g, style:"reserved"}, 10 | globals: {reg: /\b(document|window|Array|String|Object|Number|Math|\$)(?=[^\w])/g, style:"globals"}, 11 | js: {reg: /\b(getElementsBy(TagName|ClassName|Name)|getElementById|typeof|instanceof)(?=[^\w])/g, style:"js"}, 12 | //methods: {reg:/((?<=\.)\w+)/g, style:"method"}, 13 | // Note: Safari doesn't support lookbehind in regexes ... 14 | htmlTags: {reg: /(<[^\&]*>)/g, style:"html"}, 15 | blockComments: {reg: /(\/\*.*\*\/)/g, style:"comment"}, 16 | inlineComments: {reg: /(\/\/.*)/g, style:"comment"}, 17 | source: {reg: /\b(source|\$)(?=[^\w])/g, style:"source"}, 18 | target: {reg: /\b(target|\$)(?=[^\w])/g, style:"target"}, 19 | } 20 | 21 | let editor = document.createElement("code"); 22 | let textarea = document.createElement("textarea"); 23 | editor.contentEditable = "true"; 24 | editor.spellcheck = false; 25 | editor.onkeydown = (e)=>{ 26 | e.stopPropagation(); 27 | } 28 | editor.onblur = function(){ 29 | onChange(editor.innerText); 30 | highlight(); 31 | } 32 | parent.appendChild(editor); 33 | 34 | me.setValue=(value)=>{ 35 | editor.innerText = value; 36 | highlight(); 37 | } 38 | 39 | me.onChange =()=>{ 40 | onChange(editor.innerText); 41 | } 42 | 43 | function highlight(){ 44 | textarea.innerText=editor.innerText; 45 | let text = textarea.innerHTML; 46 | 47 | for (let key in regexes){ 48 | let reg = regexes[key]; 49 | let wrap = reg.wrap || ""; 50 | text = text.replace(reg.reg,''+wrap+'$1'+wrap+''); 51 | } 52 | 53 | editor.innerHTML = text; 54 | } 55 | 56 | 57 | return me; 58 | } 59 | 60 | export default SyntaxEdit; -------------------------------------------------------------------------------- /_script/alchemy/speckles.js: -------------------------------------------------------------------------------- 1 | let process = function(source,target){ 2 | let expose = { 3 | count:{main:true,min:1,max:1000,value:500}, 4 | jitterRadius:{min:0,max:1000,value:1}, 5 | jitterPosition:{min:0,max:100,value:50}, 6 | darkAlpha:{min:0,max:100,value:3}, 7 | lightAlpha:{min:0,max:100,value:6}, 8 | horizontalShift:{min:-10,max:10,value:0}, 9 | verticalShift:{min:-10,max:10,value:0}, 10 | }; 11 | 12 | 13 | 14 | let w = source.width; 15 | let h = source.height; 16 | target.clearRect(0,0,target.canvas.width,target.canvas.height); 17 | target.drawImage(source,0,0); 18 | 19 | function dot(color,alpha){ 20 | let x = Math.floor(Math.random()*w); 21 | let y = Math.floor(Math.random()*h); 22 | target.fillStyle = toRGBA(color,alpha); 23 | splash(x,y,1,expose.jitterPosition.value,expose.jitterRadius.value/100); 24 | } 25 | 26 | for (let i = 0;i 3 | 4 | 5 | 26 | 27 | 28 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /_script/alchemy/glow.js: -------------------------------------------------------------------------------- 1 | let process = function(source,target){ 2 | let mode; 3 | mode = "MUI"; 4 | //mode = "COLOR"; 5 | mode = "ALPHA"; 6 | 7 | let colors; 8 | if (mode==="MUI"){ 9 | colors=[ 10 | [255,255,255], 11 | [255, 169, 151], 12 | [170, 144, 124], 13 | [170, 144, 124,255] 14 | ] 15 | colors.forEach(color=>outline(target,color)); 16 | } 17 | 18 | if (mode==="COLOR"){ 19 | colors=[ 20 | [255,255,255], 21 | [239, 231, 20], 22 | [221, 187, 68], 23 | [221, 187, 68,255] 24 | ] 25 | colors.forEach(color=>outline(target,color)); 26 | } 27 | 28 | function outline(ctx,color,dotted){ 29 | let w = ctx.canvas.width; 30 | let h = ctx.canvas.height; 31 | let data = ctx.getImageData(0,0,w,h); 32 | let d = data.data; 33 | let target = []; 34 | 35 | if (color.length>3) dotted=true; 36 | 37 | function checkPixel(index){ 38 | if (d[index+3] === 0){ 39 | target.push(index); 40 | } 41 | } 42 | 43 | for (let y=1; y{ 58 | d[index] = color[0]; 59 | d[index+1] = color[1]; 60 | d[index+2] = color[2]; 61 | d[index+3] = 255; 62 | 63 | if (dotted){ 64 | let x = (index/4)%w; 65 | let y = Math.floor((index/4)/w); 66 | if (x%2){ 67 | d[index+3] = y%2?0:255; 68 | }else{ 69 | d[index+3] = y%2?255:0; 70 | } 71 | } 72 | }); 73 | target=[]; 74 | ctx.putImageData(data,0,0); 75 | }else{ 76 | return target; 77 | } 78 | 79 | } 80 | 81 | } 82 | 83 | export default process; 84 | -------------------------------------------------------------------------------- /_script/ui/components/gridOverlay.js: -------------------------------------------------------------------------------- 1 | import {$div} from "../../util/dom.js"; 2 | import ImageFile from "../../image.js"; 3 | import GridPanel from "../toolPanels/gridPanel.js"; 4 | 5 | let GridOverlay = ((parent)=>{ 6 | let container = parent; 7 | let me={}; 8 | let visible = false; 9 | let grid; 10 | let zoom = 1; 11 | let linesVertical = []; 12 | let linesHorizontal = []; 13 | 14 | me.toggle=()=>{ 15 | visible = !visible; 16 | if (visible && !grid){ 17 | grid = $div("grid","",container); 18 | } 19 | grid.style.display = visible?"block":"none"; 20 | me.update(); 21 | } 22 | 23 | me.update = (rebuild)=>{ 24 | let options = GridPanel.getOptions(); 25 | visible = options.visible; 26 | if (!visible) return; 27 | if (!grid){ 28 | grid = $div("grid","",container); 29 | } 30 | 31 | let w = ImageFile.getCurrentFile().width; 32 | let h = ImageFile.getCurrentFile().height; 33 | let size = options.size || 16; 34 | grid.innerHTML = ""; 35 | grid.style.opacity = options.opacity/100; 36 | grid.style.filter = "brightness(" + (options.brightness) + "%)"; 37 | 38 | linesVertical = []; 39 | linesHorizontal = []; 40 | 41 | for (let x=size;x{ 55 | zoom = factor; 56 | let options = GridPanel.getOptions(); 57 | visible = options.visible; 58 | if (!visible) return; 59 | 60 | let size = options.size || 16; 61 | console.log("zooming grid",size,zoom); 62 | linesVertical.forEach((line,index)=>{ 63 | line.style.left = (size*zoom*(index+1)) + "px"; 64 | console.log("line",line.style.left); 65 | }); 66 | linesHorizontal.forEach((line,index)=>{ 67 | line.style.top = (size*zoom*(index+1)) + "px"; 68 | }); 69 | 70 | 71 | } 72 | 73 | return me; 74 | 75 | }); 76 | 77 | export default GridOverlay; -------------------------------------------------------------------------------- /_script/host/host.js: -------------------------------------------------------------------------------- 1 | import AmiBase from "./amibase.js"; 2 | import ImageFile from "../image.js"; 3 | import Amibase from "./amibase.js"; 4 | 5 | let Host = function(){ 6 | let me = {}; 7 | let currentFile; 8 | 9 | me.init = function(){ 10 | AmiBase.init().then(isAmiBase=>{ 11 | if (isAmiBase){ 12 | console.log("AmiBase is available"); 13 | AmiBase.setMessageHandler(message=>{ 14 | console.error(message); 15 | 16 | var command = message.message; 17 | if (!command) return; 18 | if (command.indexOf("amibase_")>=0) command=command.replace("amibase_",""); 19 | 20 | switch(command){ 21 | case 'dropFile': 22 | case 'openFile': 23 | // amiBase requests to open a file in Monaco 24 | let file = message.data; 25 | currentFile = file; 26 | if (file && file.data){ 27 | // we already have the content of the file 28 | loadFile(file.data,file.path); 29 | }else{ 30 | // we need to request the file from AmiBase 31 | AmiBase.readFile(currentFile.path,true).then(data=>{ 32 | loadFile(data,currentFile.path); 33 | }); 34 | } 35 | break; 36 | } 37 | }); 38 | AmiBase.iAmReady(); 39 | } 40 | }); 41 | } 42 | 43 | me.saveFile = function(blob,fileName){ 44 | AmiBase.requestFileSave(currentFile.path).then(file=>{ 45 | if (file && file.path){ 46 | AmiBase.writeFile(file.path,blob,true).then(result=>{ 47 | console.log(result); 48 | AmiBase.activateWindow(); 49 | }); 50 | } 51 | }); 52 | } 53 | 54 | function loadFile(data,path){ 55 | let fileName = path.split("/").pop(); 56 | console.log("load file",typeof data); 57 | ImageFile.handleBinary(data,fileName,"file",true); 58 | //SaveDialog.setFile(file); 59 | } 60 | 61 | return me; 62 | }(); 63 | 64 | export default Host; -------------------------------------------------------------------------------- /_img/magicwand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 43 | 44 | 45 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /_img/pipette_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/licenses.txt: -------------------------------------------------------------------------------- 1 | Uses (parts of) the following packages: 2 | 3 | StackBlur.js 4 | MIT License - Copyright (c) 2010 Mario Klingemann 5 | https://github.com/flozz/StackBlur/ 6 | 7 | Dithering techniques based on PhotoDemon 8 | Simplified BSD license - Copyright 2002-2023 by Tanner Helland 9 | https://github.com/tannerhelland/PhotoDemon/blob/main/Forms/Adjustments_BlackAndWhite.frm 10 | https://tannerhelland.com/2012/12/28/dithering-eleven-algorithms-source-code.html 11 | 12 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 13 | 14 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 15 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, 17 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 18 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 19 | OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, 20 | OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 22 | THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | Sharpen Algorithm 25 | mikecao and jaredatdannyronsrescue 26 | https://gist.github.com/mikecao/65d9fc92dc7197cb8a7c?permalink_comment_id=4314200#gistcomment-4314200 27 | 28 | Image Scale Algorithm 29 | MIT License - Copyright (c) 2017 Eugene Tiurin 30 | https://github.com/ytiurin/downscale/blob/master/src/downsample.js 31 | 32 | FileSaver.js - used as fallback for older browsers 33 | MIT License - Copyright © 2016 Eli Grey 34 | https://github.com/eligrey/FileSaver.js 35 | 36 | LZWDecoder - used for GIF decoding 37 | MIT License - Copyright (c) 2015 Matt Way 38 | https://github.com/matt-way/gifuct-js/blob/master/src/lzw.js 39 | 40 | Additional Retro Platform palettes by Zeb Elwood 41 | -------------------------------------------------------------------------------- /_data/palettes/TED-Plus4-C16.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "palette", 3 | "palette": [ 4 | "#000000", 5 | "#202020", 6 | "#404040", 7 | "#606060", 8 | "#808080", 9 | "#9f9f9f", 10 | "#bfbfbf", 11 | "#dfdfdf", 12 | "#5d0800", 13 | "#7d2819", 14 | "#9c4839", 15 | "#bc6859", 16 | "#dc8879", 17 | "#fca899", 18 | "#ffc8b9", 19 | "#ffe8d9", 20 | "#003746", 21 | "#035766", 22 | "#237786", 23 | "#4397a6", 24 | "#63b7c6", 25 | "#82d7e6", 26 | "#a2f7ff", 27 | "#c2ffff", 28 | "#5d006d", 29 | "#7d128d", 30 | "#9c32ac", 31 | "#bc52cc", 32 | "#dc71ec", 33 | "#fc91ff", 34 | "#ffb1ff", 35 | "#ffd1ff", 36 | "#004e00", 37 | "#036e00", 38 | "#238e13", 39 | "#43ad33", 40 | "#63cd53", 41 | "#82ed72", 42 | "#a2ff92", 43 | "#c2ffb2", 44 | "#20116d", 45 | "#40318d", 46 | "#6051ac", 47 | "#8071cc", 48 | "#9f90ec", 49 | "#bfb0ff", 50 | "#dfd0ff", 51 | "#fff0ff", 52 | "#202f00", 53 | "#404f00", 54 | "#606f13", 55 | "#808e33", 56 | "#9fae53", 57 | "#bfce72", 58 | "#dfee92", 59 | "#ffffb2", 60 | "#004600", 61 | "#036619", 62 | "#238639", 63 | "#43a659", 64 | "#63c679", 65 | "#82e699", 66 | "#a2ffb9", 67 | "#c2ffd9", 68 | "#5d1000", 69 | "#7d3000", 70 | "#9c5013", 71 | "#bc6f33", 72 | "#dc8f53", 73 | "#fcaf72", 74 | "#ffcf92", 75 | "#ffefb2", 76 | "#3e1f00", 77 | "#5e3f00", 78 | "#7e5f13", 79 | "#9e7f33", 80 | "#be9f53", 81 | "#debf72", 82 | "#fedf92", 83 | "#fffeb2", 84 | "#013e00", 85 | "#215e00", 86 | "#417e13", 87 | "#619e33", 88 | "#81be53", 89 | "#a1de72", 90 | "#c1fe92", 91 | "#e1ffb2", 92 | "#5d0120", 93 | "#7d2140", 94 | "#9c4160", 95 | "#bc6180", 96 | "#dc809f", 97 | "#fca0bf", 98 | "#ffc0df", 99 | "#ffe0ff", 100 | "#003f20", 101 | "#035f40", 102 | "#237f60", 103 | "#439e80", 104 | "#63be9f", 105 | "#82debf", 106 | "#a2fedf", 107 | "#00306d", 108 | "#03508d", 109 | "#2370ac", 110 | "#4390cc", 111 | "#63afec", 112 | "#82cfff", 113 | "#a2efff", 114 | "#3e016d", 115 | "#5e218d", 116 | "#7e41ac", 117 | "#9e61cc", 118 | "#be81ec", 119 | "#dea1ff", 120 | "#fec1ff", 121 | "#ffe1ff", 122 | "#ffffff" 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /_img/gif.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /_img/psd.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /_img/jpeg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /_script/alchemy/texture.js: -------------------------------------------------------------------------------- 1 | /* Attempt to mimic the "texture" effect in Adobe Camera RAW 2 | * Experimental and cpu intensive - might crash from time to time 3 | * Steffest 4 | * */ 5 | 6 | let process = function(source,target){ 7 | let w = source.width; 8 | let h = source.height; 9 | function sharpen(ctx, w, h, mix){ 10 | var x, sx, sy, r, g, b, a, dstOff, srcOff, wt, cx, cy, scy, scx, 11 | weights = [0, -1, 0, -1, 5, -1, 0, -1, 0], 12 | katet = Math.round(Math.sqrt(weights.length)), 13 | half = (katet * 0.5) | 0, 14 | dstData = ctx.createImageData(w, h), 15 | dstBuff = dstData.data, 16 | srcBuff = ctx.getImageData(0, 0, w, h).data, 17 | y = h; 18 | while (y--) { 19 | x = w; 20 | while (x--) { 21 | sy = y; 22 | sx = x; 23 | dstOff = (y * w + x) * 4; 24 | r = 0; 25 | g = 0; 26 | b = 0; 27 | a = 0; 28 | if(x>0 && y>0 && x= 0 && scy < h && scx >= 0 && scx < w) { 35 | srcOff = (scy * w + scx) * 4; 36 | wt = weights[cy * katet + cx]; 37 | 38 | r += srcBuff[srcOff] * wt; 39 | g += srcBuff[srcOff + 1] * wt; 40 | b += srcBuff[srcOff + 2] * wt; 41 | a += srcBuff[srcOff + 3] * wt; 42 | } 43 | } 44 | } 45 | 46 | dstBuff[dstOff] = r * mix + srcBuff[dstOff] * (1 - mix); 47 | dstBuff[dstOff + 1] = g * mix + srcBuff[dstOff + 1] * (1 - mix); 48 | dstBuff[dstOff + 2] = b * mix + srcBuff[dstOff + 2] * (1 - mix); 49 | dstBuff[dstOff + 3] = srcBuff[dstOff + 3]; 50 | } else { 51 | dstBuff[dstOff] = srcBuff[dstOff]; 52 | dstBuff[dstOff + 1] = srcBuff[dstOff + 1]; 53 | dstBuff[dstOff + 2] = srcBuff[dstOff + 2]; 54 | dstBuff[dstOff + 3] = srcBuff[dstOff + 3]; 55 | } 56 | } 57 | } 58 | 59 | ctx.putImageData(dstData, 0, 0); 60 | } 61 | 62 | } 63 | 64 | export default process; -------------------------------------------------------------------------------- /_style/_fileBrowser.scss: -------------------------------------------------------------------------------- 1 | .filebrowser{ 2 | position: absolute; 3 | right: 0; 4 | top: 27px; 5 | width: 150px; 6 | bottom: 22px; 7 | background-color: $panel-background-color; 8 | border: 1px solid black; 9 | display: none; 10 | overflow-y: auto; 11 | overflow-x: hidden; 12 | 13 | &.active{ 14 | display: block; 15 | } 16 | 17 | .caption{ 18 | color: $menu-text-color; 19 | padding: 4px 5px 2px 20px; 20 | font-size: 12px; 21 | height: 21px; 22 | border-bottom: 1px solid black; 23 | 24 | 25 | .close{ 26 | position: absolute; 27 | height: 20px; 28 | width: 20px; 29 | line-height: 20px; 30 | left: 0; 31 | top: 0; 32 | text-align: center; 33 | cursor: pointer; 34 | } 35 | } 36 | 37 | .disk{ 38 | padding: 2px 2px 2px 21px; 39 | border-bottom: 1px solid black; 40 | color: $menu-text-color; 41 | font-size: 11px; 42 | line-height: 20px; 43 | background-image: url("../_img/disk.svg"); 44 | background-size: 16px 16px; 45 | background-repeat: no-repeat; 46 | background-position: 2px center; 47 | position: relative; 48 | 49 | .download{ 50 | position: absolute; 51 | height: 16px; 52 | width: 16px; 53 | right: 2px; 54 | top: 2px; 55 | cursor: pointer; 56 | opacity: 0.7; 57 | background-image: url("../_img/download.svg"); 58 | background-size: contain; 59 | background-repeat: no-repeat; 60 | background-position: center center; 61 | 62 | &:hover{ 63 | opacity: 1; 64 | } 65 | } 66 | } 67 | 68 | .listitem{ 69 | color: $menu-text-color; 70 | padding: 2px 2px 2px 21px; 71 | border-bottom: 1px solid black; 72 | position: relative; 73 | 74 | &.folder{ 75 | background-image: url("../_img/folder.svg"); 76 | background-size: 16px 16px; 77 | background-repeat: no-repeat; 78 | background-position: 2px center; 79 | } 80 | 81 | &.file{ 82 | opacity: 0.5; 83 | &:before{ 84 | content: ""; 85 | position: absolute; 86 | left: 5px; 87 | top: 5px; 88 | width: 10px; 89 | height: 10px; 90 | border-radius: 5px; 91 | background-color: grey; 92 | } 93 | 94 | &.image{ 95 | opacity: 1; 96 | &:before{ 97 | background-color: #71b471; 98 | } 99 | } 100 | } 101 | 102 | &:hover{ 103 | cursor: pointer; 104 | background-color: $panel-background-active; 105 | color: white; 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /_data/palettes/Atari-2600-NTSC.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "palette", 3 | "palette": [ 4 | "#000000", 5 | "#444400", 6 | "#702800", 7 | "#841800", 8 | "#880000", 9 | "#78005c", 10 | "#480078", 11 | "#140084", 12 | "#000088", 13 | "#00187c", 14 | "#002c5c", 15 | "#00402c", 16 | "#003c00", 17 | "#143800", 18 | "#2c3000", 19 | "#442800", 20 | "#404040", 21 | "#646410", 22 | "#844414", 23 | "#983418", 24 | "#9c2020", 25 | "#8c2074", 26 | "#602090", 27 | "#302098", 28 | "#1c209c", 29 | "#1c3890", 30 | "#1c4c78", 31 | "#1c5c48", 32 | "#205c20", 33 | "#345c1c", 34 | "#4c501c", 35 | "#644818", 36 | "#6c6c6c", 37 | "#848424", 38 | "#985c28", 39 | "#ac5030", 40 | "#b03c3c", 41 | "#a03c88", 42 | "#783ca4", 43 | "#4c3cac", 44 | "#3840b0", 45 | "#3854a8", 46 | "#386890", 47 | "#387c64", 48 | "#407c40", 49 | "#507c38", 50 | "#687034", 51 | "#846830", 52 | "#909090", 53 | "#a0a034", 54 | "#ac783c", 55 | "#c06848", 56 | "#c05858", 57 | "#b0589c", 58 | "#8c58b8", 59 | "#6858c0", 60 | "#505cc0", 61 | "#5070bc", 62 | "#5084ac", 63 | "#509c80", 64 | "#5c9c5c", 65 | "#6c9850", 66 | "#848c4c", 67 | "#a08444", 68 | "#b0b0b0", 69 | "#b8b840", 70 | "#bc8c4c", 71 | "#d0805c", 72 | "#d07070", 73 | "#c070b0", 74 | "#a070cc", 75 | "#7c70d0", 76 | "#6874d0", 77 | "#6888cc", 78 | "#689cc0", 79 | "#68b494", 80 | "#74b474", 81 | "#84b468", 82 | "#9ca864", 83 | "#b89c58", 84 | "#c8c8c8", 85 | "#d0d050", 86 | "#cca05c", 87 | "#e09470", 88 | "#e08888", 89 | "#d084c0", 90 | "#b484dc", 91 | "#9488e0", 92 | "#7c8ce0", 93 | "#7c9cdc", 94 | "#7cb4d4", 95 | "#7cd0ac", 96 | "#8cd08c", 97 | "#9ccc7c", 98 | "#b4c078", 99 | "#d0b46c", 100 | "#dcdcdc", 101 | "#e8e85c", 102 | "#dcb468", 103 | "#eca880", 104 | "#eca0a0", 105 | "#dc9cd0", 106 | "#c49cec", 107 | "#a8a0ec", 108 | "#90a4ec", 109 | "#90b4ec", 110 | "#90cce8", 111 | "#90e4c0", 112 | "#a4e4a4", 113 | "#b4e490", 114 | "#ccd488", 115 | "#e8cc7c", 116 | "#ececec", 117 | "#fcfc68", 118 | "#fcbc94", 119 | "#fcb4b4", 120 | "#ecb0e0", 121 | "#d4b0fc", 122 | "#bcb4fc", 123 | "#a4b8fc", 124 | "#a4c8fc", 125 | "#a4e0fc", 126 | "#a4fcd4", 127 | "#b8fcb8", 128 | "#c8fca4", 129 | "#e0ec9c", 130 | "#fce08c", 131 | "#ffffff" 132 | ] 133 | } 134 | -------------------------------------------------------------------------------- /_img/flask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 22 | 23 | 24 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /_style/_ditherEditor.scss: -------------------------------------------------------------------------------- 1 | .ditheredit{ 2 | .preset{ 3 | display: inline-block; 4 | width: 50px; 5 | height: 50px; 6 | border: 1px solid black; 7 | background-size: 50% 50%; 8 | background-color: #6b6b6b; 9 | @include pixelated; 10 | 11 | &:hover{ 12 | border: 1px solid $active-color; 13 | background-color: #bbbbbb; 14 | cursor: pointer; 15 | } 16 | 17 | &.p0{ 18 | background-image: url("../_img/patterns/dots.png"); 19 | } 20 | &.p1{ 21 | background-image: url("../_img/patterns/cross.png"); 22 | } 23 | &.p2{ 24 | background-image: url("../_img/patterns/grid.png"); 25 | } 26 | &.p3{ 27 | background-image: url("../_img/patterns/cross2.png"); 28 | } 29 | &.p4{ 30 | background-image: url("../_img/patterns/lines_hor.png"); 31 | } 32 | &.p5{ 33 | background-image: url("../_img/patterns/lines_ver.png"); 34 | } 35 | &.p6{ 36 | background-image: url("../_img/patterns/lines_diag.png"); 37 | } 38 | &.p7{ 39 | background-image: url("../_img/patterns/longgrid.png"); 40 | } 41 | &.user{ 42 | canvas{ 43 | width: 100%; 44 | height: 100%; 45 | } 46 | } 47 | 48 | 49 | 50 | } 51 | 52 | .editpreset, 53 | .previewpreset, 54 | .presets{ 55 | position: absolute; 56 | left: 5px; 57 | top: 5px; 58 | bottom: 5px; 59 | width: 202px; 60 | border: 1px solid black; 61 | } 62 | 63 | .editpreset{ 64 | left: 210px; 65 | width: 242px; 66 | } 67 | 68 | .previewpreset{ 69 | left: 455px; 70 | canvas{ 71 | background-image: url("../_img/patterns/gradient.png"); 72 | background-size: 100% 100%; 73 | } 74 | } 75 | 76 | h2{ 77 | font-size: 12px; 78 | padding: 4px; 79 | border-bottom: 1px solid black; 80 | font-weight: normal; 81 | margin: 0; 82 | } 83 | 84 | .subtoolbar{ 85 | border-top: 1px solid black; 86 | text-align: right; 87 | font-size: 12px; 88 | position: relative; 89 | padding: 0; 90 | 91 | .button{ 92 | display: inline-block; 93 | padding: 4px 9px; 94 | text-align: center; 95 | white-space: nowrap; 96 | 97 | &.small{ 98 | width: 24px; 99 | padding: 4px; 100 | } 101 | 102 | &.active{ 103 | border-color: $active-color-dim; 104 | color: $active-color-dim; 105 | background-color: #413e28; 106 | } 107 | 108 | &.hidden{ 109 | display: none; 110 | } 111 | 112 | &.left{ 113 | position: absolute; 114 | left: 0; 115 | max-width: 49%; 116 | } 117 | } 118 | } 119 | 120 | } -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | - Improve Undo/redo 2 | - Add/Remove from selection 3 | - invert selection 4 | - dragging of panels? 5 | - Store/restore UI settings (panels states etc) 6 | - improve rotate layer/selection 7 | - polygon mask - deduplicate points 8 | - pen support (TouchData force => https://developer.mozilla.org/en-US/docs/Web/API/Touch/force ) 9 | - small screen support 10 | - copy image larger than canvas => keep entire image? 11 | - deselect after resize 12 | - ctrl-mousewheel should be zoom 13 | - save to JPG including EXIF data 14 | - EXIF editor? 15 | - selection tool should hide the paint-shape cursor 16 | - open recent files? 17 | - add layer mask should activate it 18 | - painting on layer should show color in grayscale 19 | - clone stamp tool ? 20 | - use OffscreenCanvas 21 | 22 | 23 | - filters: e.g. https://medium.com/skylar-salernos-tech-blog/mimicking-googles-pop-filter-using-canvas-blend-modes-d7da83590d1a 24 | 25 | 26 | - load palette directly from Lospec? https://lospec.com/palette-list/load?colorNumberFilterType=any&colorNumber=8&page=0&tag=&sortingType=default 27 | 28 | - Export to TIFF: https://github.com/motiz88/canvas-to-tiff 29 | 30 | Bugs: 31 | 32 | 33 | Changing the palette color depth squashes all layers? 34 | Transforming a mask clears the layer ? 35 | resizing sizebox negatively, makes the sizebox 0 width/height 36 | resize image to larger doesn't clear all cached data (like the drawlayer?) 37 | copy/paste/undo shortcut doesn't work on Firefox 38 | fill tool is active when scrollbar is clicked 39 | when applying color palette changes - they get applied to the top layer instead of the active layer 40 | scrolling removes selection mask 41 | How to transform layer in touch screens? 42 | Drawing a line over the edge wraps around to the other side 43 | Cropping an image should never enlarge the canvas (if the selection is greater than the canvas) 44 | When resizing the canvas, the selection should be resized as well 45 | drag/drop should import as new layer ? 46 | Transparency settings are not applied on canvas brush 47 | Palette Edit - pick color from image: apply button is not shown 48 | INVERT SELECTION! 49 | Save as Gif - when used in 24-bit alpha image, a black outcome 50 | 51 | 52 | TODO before release: 53 | (why was there a check for "meta key down"? rotate brush stuff? 54 | eydropper should not be active while drawing a line) 55 | ); 56 | 57 | feature previews 58 | 59 | - Animated GIF 60 | - color cycling 61 | - pressure sensitivity 62 | - Palette lock 63 | - 12 and 9 bit color depths 64 | - resizable panels 65 | 66 | Amiga Specific 67 | - 12 bit color depth 68 | - Color Cycle with IFF export 69 | 70 | 71 | 72 | Rescale bug: 73 | 74 | start with a layer 75 | have area in the clipboard with a diffrent size 76 | transform first layer 77 | reposition the layer 78 | paste the clipboard 79 | transform (v) 80 | -> the fist layer is rescaled to the pasted clipboard size 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/rotate_brush.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('Rotate Brush Functionality', async ({ page }) => { 4 | // 1. Navigate to the app 5 | await page.goto('/index.html'); 6 | await expect(page.locator('.panel.left .maincanvas')).toBeVisible(); 7 | 8 | // Wait for the "About" panel to appear and close it 9 | const aboutPanel = page.locator('.modalwindow'); 10 | await expect(aboutPanel).toBeVisible(); 11 | // Click the close button (x) in the caption 12 | await page.locator('.modalwindow .caption .button').click(); 13 | await expect(aboutPanel).toBeHidden(); 14 | 15 | // 2. Select the "Select" tool 16 | const selectTool = page.locator('.button.icon.select'); 17 | await selectTool.click(); 18 | await expect(selectTool).toHaveClass(/active/); 19 | 20 | // 3. Make a rectangular selection (20x10) 21 | const canvas = page.locator('.panel.left .maincanvas'); 22 | const box = await canvas.boundingBox(); 23 | if (!box) throw new Error('Canvas bounding box not found'); 24 | 25 | const startX = box.x + 50; 26 | const startY = box.y + 50; 27 | const width = 20; 28 | const height = 10; 29 | 30 | await page.mouse.move(startX, startY); 31 | await page.mouse.down(); 32 | await page.mouse.move(startX + width, startY + height); 33 | await page.mouse.up(); 34 | 35 | // 4. Trigger "Brush -> From Selection" 36 | const brushMenu = page.locator('.menuitem.main:text-matches("^Brush", "i")'); 37 | await brushMenu.click(); 38 | await expect(brushMenu).toHaveClass(/active/); 39 | 40 | const fromSelectionItem = page.locator(".menuitem.sub a:text-matches('^From Selection', 'i')"); 41 | await expect(fromSelectionItem).toBeVisible(); 42 | await fromSelectionItem.click(); 43 | 44 | // Wait for Brush module to be available 45 | await page.waitForFunction(() => typeof window.Brush !== 'undefined'); 46 | 47 | // 5. Verify initial brush dimensions (should be 20x10) 48 | let brushDimensions = await page.evaluate(() => { 49 | // @ts-ignore 50 | const brush = window.Brush.get(); 51 | return { width: brush.width, height: brush.height }; 52 | }); 53 | 54 | // Note: Selection might be slightly off due to mouse movement precision, but should be close. 55 | // Actually, let's just check that width > height 56 | expect(brushDimensions.width).toBeGreaterThan(brushDimensions.height); 57 | console.log('Initial Brush:', brushDimensions); 58 | 59 | // 6. Trigger "Brush -> Transform -> Rotate Right" using keyboard shortcut 60 | await page.keyboard.press('Control+Shift+ArrowRight'); 61 | 62 | // 7. Verify rotated brush dimensions (should be 10x20, so height > width) 63 | brushDimensions = await page.evaluate(() => { 64 | // @ts-ignore 65 | const brush = window.Brush.get(); 66 | return { width: brush.width, height: brush.height }; 67 | }); 68 | console.log('Rotated Brush:', brushDimensions); 69 | 70 | expect(brushDimensions.height).toBeGreaterThan(brushDimensions.width); 71 | }); 72 | -------------------------------------------------------------------------------- /_script/ui/cursor.js: -------------------------------------------------------------------------------- 1 | import {$div} from "../util/dom.js"; 2 | import Eventbus from "../util/eventbus.js"; 3 | import Input from "./input.js"; 4 | import {COMMAND, EVENT} from "../enum.js"; 5 | import Editor from "./editor.js"; 6 | 7 | var Cursor = function(){ 8 | var me = {} 9 | var cursor; 10 | var cursorMark; 11 | var toolTip; 12 | let position = {x:0,y:0} 13 | let defaultCursor = "default"; 14 | let currentCursor = undefined; 15 | let overrideCursor = undefined; 16 | 17 | me.init = function(){ 18 | cursor = $div("cursor"); 19 | cursorMark = $div("mark","",cursor); 20 | toolTip = $div("tooltip","",cursor); 21 | document.body.appendChild(cursor); 22 | 23 | document.body.addEventListener("pointermove", function (e) { 24 | position = {x:e.clientX,y:e.clientY}; 25 | cursor.style.left = e.clientX + "px"; 26 | cursor.style.top = e.clientY + "px"; 27 | }, false); 28 | 29 | Eventbus.on(EVENT.drawColorChanged,(color)=>{ 30 | cursorMark.style.borderColor = "rgb(" + color[0] + "," + color[1] + "," + color[2] + ")"; 31 | }) 32 | } 33 | 34 | me.set = function(name){ 35 | currentCursor = name; 36 | setCursor(); 37 | } 38 | 39 | me.reset = function(){ 40 | currentCursor = undefined; 41 | setCursor(); 42 | } 43 | 44 | me.override = function(name){ 45 | overrideCursor = name; 46 | setCursor(); 47 | } 48 | 49 | me.hasOverride = function(name){ 50 | return overrideCursor === name; 51 | } 52 | 53 | me.resetOverride = function(name){ 54 | overrideCursor = undefined; 55 | setCursor(); 56 | } 57 | 58 | me.attach = function(name){ 59 | cursor.style.display = "block"; 60 | cursorMark.style.display = "block"; 61 | } 62 | 63 | 64 | me.getPosition = ()=>{ 65 | return position; 66 | } 67 | 68 | function setCursor(){ 69 | document.body.classList.forEach((c)=>{ 70 | if (c.startsWith("cursor-")) document.body.classList.remove(c); 71 | }); 72 | let cursorName = overrideCursor || currentCursor || defaultCursor; 73 | document.body.classList.add("cursor-" + cursorName); 74 | } 75 | 76 | Eventbus.on(EVENT.modifierKeyChanged,()=>{ 77 | /*if ((Input.isShiftDown() || Input.isAltDown()) && !Input.isMetaDown() && Editor.canPickColor()){ 78 | me.override("colorpicker"); 79 | }else{ 80 | me.resetOverride(); 81 | }*/ 82 | 83 | if ((Input.isShiftDown() || Input.isAltDown()) && Editor.canPickColor(Input.isPointerDown())){ 84 | me.override("colorpicker"); 85 | }else{ 86 | me.resetOverride(); 87 | } 88 | 89 | 90 | if (Input.isSpaceDown()){ 91 | me.override("pan"); 92 | } 93 | }) 94 | 95 | return me; 96 | }(); 97 | 98 | export default Cursor; -------------------------------------------------------------------------------- /tests/line_tool.spec.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { test, expect } from '@playwright/test'; 3 | 4 | test('Line Tool Functionality', async ({ page }) => { 5 | // 1. Navigate to the app 6 | await page.goto('/index.html'); 7 | await expect(page.locator('.panel.left .maincanvas')).toBeVisible(); 8 | 9 | // Wait for the "About" panel to appear and close it 10 | const aboutPanel = page.locator('.modalwindow'); 11 | await expect(aboutPanel).toBeVisible(); 12 | // Click the close button (x) in the caption 13 | await page.locator('.modalwindow .caption .button').click(); 14 | await expect(aboutPanel).toBeHidden(); 15 | 16 | // 2. Select the Line tool 17 | const lineTool = page.locator('.button.icon.line'); 18 | await lineTool.click(); 19 | await expect(lineTool).toHaveClass(/active/); 20 | 21 | // 3. Draw a horizontal line (50, 50) -> (150, 50) 22 | const canvas = page.locator('.panel.left .maincanvas'); 23 | const box = await canvas.boundingBox(); 24 | if (!box) throw new Error('Canvas bounding box not found'); 25 | 26 | await page.mouse.move(box.x + 50, box.y + 50); 27 | await page.mouse.down(); 28 | await page.mouse.move(box.x + 150, box.y + 50); 29 | await page.mouse.up(); 30 | 31 | // 4. Draw a vertical line (50, 50) -> (50, 150) 32 | await page.mouse.move(box.x + 50, box.y + 50); 33 | await page.mouse.down(); 34 | await page.mouse.move(box.x + 50, box.y + 150); 35 | await page.mouse.up(); 36 | 37 | // Helper to get canvas pixel color at (x, y) 38 | async function getPixelColor(x, y) { 39 | return await page.evaluate(({ x, y }) => { 40 | const canvas = /** @type {HTMLCanvasElement} */ (document.querySelector('.panel.left .maincanvas')); 41 | if (!canvas) return [0, 0, 0, 0]; 42 | const ctx = canvas.getContext('2d'); 43 | if (!ctx) return [0, 0, 0, 0]; 44 | const p = ctx.getImageData(x, y, 1, 1).data; 45 | return [p[0], p[1], p[2], p[3]]; 46 | }, { x, y }); 47 | } 48 | 49 | // 5. Verify pixels 50 | // Default draw color is black [0, 0, 0, 255] 51 | 52 | // Check point on horizontal line 53 | let color = await getPixelColor(100, 50); 54 | expect(color).toEqual([0, 0, 0, 255]); 55 | 56 | // Check point on vertical line 57 | color = await getPixelColor(50, 100); 58 | expect(color).toEqual([0, 0, 0, 255]); 59 | 60 | // Check intersection 61 | color = await getPixelColor(50, 50); 62 | expect(color).toEqual([0, 0, 0, 255]); 63 | 64 | // Check point NOT on line (should be transparent or background) 65 | // Default background is transparent [0, 0, 0, 0] or white depending on init 66 | // Based on previous tests, it seems to be transparent or white. 67 | // Let's check a point far away. 68 | color = await getPixelColor(200, 200); 69 | // Expecting transparent or white. Let's check alpha. 70 | // If alpha is 0, it's transparent. 71 | if (color[3] !== 0) { 72 | // If not transparent, assume white background [255, 255, 255, 255] 73 | expect(color).toEqual([255, 255, 255, 255]); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /tests/fill_tool.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('Fill Tool Functionality', async ({ page }) => { 4 | // 1. Navigate to the app 5 | await page.goto('/index.html'); 6 | await expect(page.locator('.panel.left .maincanvas')).toBeVisible(); 7 | 8 | // Wait for the "About" panel to appear and close it 9 | const aboutPanel = page.locator('.modalwindow'); 10 | await expect(aboutPanel).toBeVisible(); 11 | // Click the close button (x) in the caption 12 | await page.locator('.modalwindow .caption .button').click(); 13 | await expect(aboutPanel).toBeHidden(); 14 | 15 | // Helper to select color from palette 16 | // Palette items are 14x14 pixels, 4 columns. 17 | // Index 3 (Blue): Row 0, Col 3 -> x=42, y=0. Center: 49, 7 18 | // Index 7 (Pink): Row 1, Col 3 -> x=42, y=14. Center: 49, 21 19 | async function selectColor(index) { 20 | const palette = page.locator('.info.palettecanvas'); 21 | const size = 14; 22 | const cols = 4; 23 | const col = index % cols; 24 | const row = Math.floor(index / cols); 25 | const x = col * size + size / 2; 26 | const y = row * size + size / 2; 27 | 28 | await palette.click({ position: { x, y } }); 29 | 30 | // Check if Palette Editor opened (e.g. due to double click) and close it 31 | const modal = page.locator('.modalwindow'); 32 | if (await modal.isVisible()) { 33 | console.log('Palette Editor opened unexpectedly, closing it.'); 34 | await page.locator('.modalwindow .caption .button').click(); 35 | await expect(modal).toBeHidden(); 36 | } 37 | } 38 | 39 | // Helper to get canvas pixel color at center 40 | async function getCanvasColor() { 41 | return await page.evaluate(() => { 42 | const canvas = /** @type {HTMLCanvasElement} */ (document.querySelector('.panel.left .maincanvas')); 43 | if (!canvas) return [0, 0, 0]; 44 | const ctx = canvas.getContext('2d'); 45 | if (!ctx) return [0, 0, 0]; 46 | const x = canvas.width / 2; 47 | const y = canvas.height / 2; 48 | const p = ctx.getImageData(x, y, 1, 1).data; 49 | return [p[0], p[1], p[2]]; 50 | }); 51 | } 52 | 53 | // 2. Select a color (Index 3: Blue [59,103,162]) 54 | await selectColor(3); 55 | 56 | // 3. Select the Fill tool 57 | const fillTool = page.locator('.button.icon.flood'); 58 | await fillTool.click(); 59 | await expect(fillTool).toHaveClass(/active/); 60 | 61 | // 4. Click on the canvas to fill 62 | const canvas = page.locator('.panel.left .maincanvas'); 63 | await canvas.click(); 64 | 65 | // 5. Verify canvas is filled with Blue 66 | let color = await getCanvasColor(); 67 | expect(color).toEqual([59, 103, 162]); 68 | 69 | // 6. Select another color (Index 7: Pink [255,169,151]) 70 | await selectColor(7); 71 | 72 | // 7. Click on the canvas to fill again 73 | await canvas.click(); 74 | 75 | // 8. Verify canvas is filled with Pink 76 | color = await getCanvasColor(); 77 | expect(color).toEqual([255, 169, 151]); 78 | }); 79 | -------------------------------------------------------------------------------- /_style/_forms.scss: -------------------------------------------------------------------------------- 1 | .checkbox{ 2 | display: block; 3 | 4 | label{ 5 | position: relative; 6 | 7 | span{ 8 | padding: 2px 0 2px 18px; 9 | line-height: 14px; 10 | white-space: nowrap; 11 | color: $menu-text-color; 12 | 13 | &:before{ 14 | content: ""; 15 | position: absolute; 16 | left: 0; 17 | top: 1px; 18 | width: 14px; 19 | height: 14px; 20 | background-color: #2b2b2b; 21 | border: 1px solid #8a8a8a; 22 | } 23 | } 24 | 25 | &:hover{ 26 | cursor: pointer; 27 | 28 | span{ 29 | color: #d7d7d7; 30 | 31 | &:before{ 32 | border: 1px solid #e8e8e8; 33 | } 34 | } 35 | } 36 | } 37 | 38 | input{ 39 | opacity: 0; 40 | position: absolute; 41 | 42 | &:checked + span{ 43 | &:before{ 44 | background-color: #c0c0c0; 45 | box-shadow: inset 0 0 0px 2px black; 46 | } 47 | } 48 | } 49 | 50 | &.small{ 51 | margin-top: 1px; 52 | label{ 53 | span{ 54 | padding: 2px 0 2px 14px; 55 | 56 | &:before{ 57 | width: 10px; 58 | height: 10px; 59 | top: 2px; 60 | } 61 | } 62 | } 63 | 64 | input{ 65 | &:checked + span{ 66 | &:before{ 67 | box-shadow: inset 0 0 0 1px black; 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | input[type="number"], 75 | input[type="text"], 76 | .inputbox{ 77 | display: inline-block; 78 | background-color: #2b2b2b; 79 | border: 1px solid #6A6A6A; 80 | color: #BBBBBB; 81 | padding: 4px; 82 | font-size: 14px; 83 | 84 | &:focus{ 85 | outline: none; 86 | } 87 | } 88 | 89 | .yesno{ 90 | display: flex; 91 | width: 70px; 92 | height: 18px; 93 | border: 1px solid #6A6A6A; 94 | margin: 2px 2px 0 0; 95 | padding-top: 1px; 96 | font-size: 12px; 97 | position: relative; 98 | overflow: hidden; 99 | 100 | .option{ 101 | width: 50px; 102 | text-align: center; 103 | opacity: 0.5; 104 | position: relative; 105 | z-index: 2; 106 | 107 | &:nth-child(2){ 108 | opacity: 1; 109 | color: black; 110 | } 111 | } 112 | 113 | &:before{ 114 | content: ""; 115 | position: absolute; 116 | left: 50%; 117 | top: 0; 118 | bottom: 0; 119 | width: 30px; 120 | background-color: $menu-text-color; 121 | transition: left 0.3s ease-in-out; 122 | } 123 | 124 | &:hover{ 125 | cursor: pointer; 126 | 127 | .option{ 128 | opacity: 1; 129 | 130 | &:nth-child(2){ 131 | opacity: 1 !important; 132 | } 133 | } 134 | } 135 | 136 | &.selected{ 137 | .option{ 138 | &:nth-child(1){ 139 | opacity: 1; 140 | color: black; 141 | } 142 | 143 | &:nth-child(2){ 144 | opacity: 0.5; 145 | color: inherit; 146 | } 147 | } 148 | 149 | &:before{ 150 | left: 0; 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /_style/_paletteList.scss: -------------------------------------------------------------------------------- 1 | .palettelist{ 2 | position: absolute; 3 | left: 70px; 4 | top: 27px; 5 | bottom: 20px; 6 | border: 1px solid black; 7 | width: 100px; 8 | background-color: #313335; 9 | z-index: 101; 10 | display: none; 11 | 12 | &.active{ 13 | display: block; 14 | box-shadow: 1px 0 2px rgba(0, 0, 0, 0.2); 15 | } 16 | 17 | .caption{ 18 | background-color: #282A2C; 19 | color: #BBBBBB; 20 | padding: 3px 5px 3px 5px; 21 | font-size: 12px; 22 | height: 21px; 23 | border-bottom: 1px solid rgba(0, 0, 0, 0.4); 24 | user-select: none; 25 | 26 | .close{ 27 | position: absolute; 28 | height: 20px; 29 | width: 20px; 30 | line-height: 20px; 31 | right: 0; 32 | top: 0; 33 | text-align: center; 34 | cursor: pointer; 35 | } 36 | } 37 | 38 | .inner{ 39 | position: absolute; 40 | left:0; 41 | right: 0; 42 | top: 20px; 43 | bottom: 0; 44 | overflow: auto; 45 | overflow-x: hidden; 46 | } 47 | 48 | .group{ 49 | color: #BBBBBB; 50 | padding: 3px 5px 3px 16px; 51 | position: relative ; 52 | font-size: 12px; 53 | border-bottom: 1px solid black; 54 | 55 | &:before{ 56 | content: ""; 57 | position: absolute; 58 | width: 16px; 59 | height: 16px; 60 | background-image: url("../_img/caret.svg"); 61 | background-repeat: no-repeat; 62 | background-size: contain; 63 | left: 1px; 64 | top: 2px; 65 | opacity: 0.7; 66 | } 67 | 68 | &.active{ 69 | &:before{ 70 | transform: rotate(90deg); 71 | } 72 | } 73 | 74 | &:hover{ 75 | cursor: pointer; 76 | background-color: #434548; 77 | 78 | &:before{ 79 | opacity: 1; 80 | } 81 | } 82 | } 83 | 84 | .palette{ 85 | padding-bottom: 10px; 86 | clear: both; 87 | background-color:#313335; 88 | display: flex; 89 | flex-wrap: wrap; 90 | justify-content: flex-end; 91 | 92 | .caption{ 93 | background-color:transparent; 94 | border-top: 1px solid rgb(67, 69, 72); 95 | width: 100%; 96 | height: auto; 97 | font-size: 11px; 98 | white-space: nowrap; 99 | } 100 | 101 | canvas{ 102 | display: block; 103 | margin: 2px; 104 | } 105 | 106 | &:hover{ 107 | background-color: #434548; 108 | cursor: pointer; 109 | 110 | .caption{ 111 | color: white; 112 | } 113 | } 114 | 115 | &:nth-child(2){ 116 | .caption{ 117 | border-top:none; 118 | } 119 | } 120 | } 121 | 122 | .button{ 123 | line-height: 14px; 124 | text-align: center; 125 | border: 1px solid rgba(141, 142, 143, 0.5); 126 | font-size: 12px; 127 | user-select: none; 128 | color: $menu-text-color; 129 | padding: 3px 0; 130 | margin: 4px 2px; 131 | 132 | &:hover{ 133 | background-color: $panel-background-active; 134 | cursor: pointer; 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /_img/stamp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 30 | 31 | 32 | 33 | 34 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/faq.html: -------------------------------------------------------------------------------- 1 |

F.A.Q.

2 | 3 |

Dude, your effects/filters are not working

4 | 5 |
6 | You are using safari, right? (or another IOS browser)
7 | Safari doesn't support Canvas filters.
8 | I might one day implement them in plain JavaScript, but currently I'm not spending too much time on all the weird and crippling quirks of Safari.
9 | Please use a modern browser like Chrome, Firefox or Edge, or at least one that doesn't actively tries to hold back the web. 10 |
11 | 12 |

Dude, When I copy and paste an image, I loose transparency

13 |
14 | Yes very often you do...
15 | This is mainly an issue with Photoshop on Windows, which doesn't save the transparency information in the clipboard in a standard way.
16 | Weirdly enough, Photoshop on Mac does preserve transparency in copy/paste.
17 | As a workaround, you can first save your file as PNG and import that instead. 18 |
19 | 20 |

Dude, what's up with this Deluxe Paint bullsh*t, it's nothing like it.

21 |
22 | I mean: the toolbox is even on the wrong side!
23 | Yep. It's certainly not a remake of Deluxe Paint, but it's heavily inspired by it, regarding Amiga spirit and pixel handling.
24 | I like to think of as "What if Deluxe Paint was made today?"
25 | Besides: Deluxe Paint on Atari has the tools at the bottom, blasphemy! 26 |
If you're looking for a more faithful recreation of Deluxe Paint, check out PyDPainter. 27 |
28 | 29 |

Can I import palettes from Lospec?

30 |
31 | Yes.
32 | Download the Lospec Palette as PNG, open that image in DPaint.js.
33 | Next to that, you can extract the palette from any image by selecting the menu Palette -> From image. 34 |
35 | 36 |

How do I save Amiga Icons in the "New Icon" format?

37 |
38 | Don't.
39 | The "New Icon" format was a hacky and messy way to solve a problem 30 years ago, but nowadays there are far better solutions.
40 | Save your icons as "Color Icon" (or "Glow Icon") and use a proper icon library on your Amiga.
41 | Please stop using the "New Icon" format, the sooner it fades out, the better.
42 | DPaint.js can read New Icons, but write support is not planned. 43 |
44 | 45 |

How do I save images as JPEG?

46 |
47 | Apologies for being opinionated and pedantic.
48 | DPaint.js is targeted at pixel art and lo-spec images.
49 | While the JPEG format is fine for high resolution photos, 50 | it's not a good match if you want to preserve each pixel of an image because it's a lossy compression that blurs and alters the image.
51 | Therefore, DPaint.js doesn't provide an option to save as JPEG.
52 |
53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /_style/_cursor.scss: -------------------------------------------------------------------------------- 1 | .cursor{ 2 | position: absolute; 3 | width: 20px; 4 | height: 20px; 5 | left: 0; 6 | top: 0; 7 | margin-top: -18px; 8 | margin-left: -2px; 9 | pointer-events: none; 10 | background-size: contain; 11 | z-index: 1002; 12 | display: none; 13 | 14 | &.rotate{ 15 | background-image: url("../_img/rotate1.svg"); 16 | margin-top: -10px; 17 | margin-left: -10px; 18 | } 19 | 20 | } 21 | 22 | body.customcursor.hoverviewport{ 23 | cursor: none; 24 | 25 | .sizebox{ 26 | cursor: none; 27 | } 28 | 29 | .cursor{ 30 | display: block; 31 | } 32 | 33 | 34 | } 35 | 36 | body.hoverviewport{ 37 | &.cursor-draw{ 38 | cursor: url("../_img/cursors/cross.png") 12 12, auto; 39 | } 40 | 41 | &.cursor-colorpicker{ 42 | cursor: url("../_img/cursors/pipette.png") 0 24, auto; 43 | 44 | canvas.overlaycanvas{ 45 | display: none; 46 | } 47 | } 48 | 49 | &.cursor-rotate{ 50 | cursor: url("../_img/cursors/rotatene.png") 0 0, auto; 51 | } 52 | 53 | &.cursor-select{ 54 | cursor: crosshair; 55 | } 56 | 57 | &.cursor-text{ 58 | cursor: text; 59 | } 60 | 61 | &.cursor-pan{ 62 | cursor: grab; 63 | 64 | canvas.overlaycanvas{ 65 | display: none; 66 | } 67 | } 68 | } 69 | 70 | /* cursors that also apply to main UI */ 71 | body, 72 | body.hoverviewport{ 73 | 74 | &.cursor-drag{ 75 | cursor: grabbing !important; 76 | .cursor{ 77 | display: none; 78 | } 79 | } 80 | } 81 | 82 | body.hovercanvas.pointerdown.cursor-colorpicker{ 83 | .cursor{ 84 | display: block; 85 | 86 | .mark{ 87 | position: absolute; 88 | pointer-events: none; 89 | display: none; 90 | width: 60px; 91 | height: 60px; 92 | margin: -14px 0 0 -26px; 93 | border: 8px solid green; 94 | border-radius: 50%; 95 | box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.5), inset 0 0 3px 1px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.5); 96 | } 97 | } 98 | } 99 | 100 | #dragelement{ 101 | position: absolute; 102 | z-index: 1002; 103 | pointer-events: none; 104 | opacity: 0; 105 | top: 0; 106 | left: 0; 107 | transition: opacity 0.3s ease-in-out; 108 | 109 | &.active{ 110 | opacity: 1; 111 | } 112 | 113 | .dragelement{ 114 | &.box{ 115 | width: 120px; 116 | padding: 4px 8px; 117 | color: $menu-text-color; 118 | background-color: #313335; 119 | border: 1px solid #000000; 120 | box-shadow: 1px 1px 2px 0 rgba(0, 0, 0, 0.63); 121 | 122 | &.frame{ 123 | width: 50px; 124 | height: 50px; 125 | font-size: 12px; 126 | text-align: center; 127 | display: flex; 128 | align-items: center; 129 | justify-content: center; 130 | } 131 | } 132 | 133 | &.tooltip{ 134 | background-color: #d0c5b1; 135 | padding: 4px 8px; 136 | border: 1px solid black; 137 | font-size: 12px; 138 | white-space: nowrap; 139 | pointer-events: none; 140 | margin: 12px 0 0 12px; 141 | } 142 | } 143 | 144 | 145 | } 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /_img/png.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /docs/licenses.html: -------------------------------------------------------------------------------- 1 |

Licenses

2 | 3 | DPaint.js uses (parts of) the following packages: 4 | 5 | 6 |

StackBlur.js

7 | MIT License - Copyright (c) 2010 Mario Klingemann
8 | https://github.com/flozz/StackBlur/
9 | 10 |

Dithering techniques based on PhotoDemon

11 | Simplified BSD license - Copyright 2002-2023 by Tanner Helland
12 | https://github.com/tannerhelland/PhotoDemon/blob/main/Forms/Adjustments_BlackAndWhite.frm
13 | https://tannerhelland.com/2012/12/28/dithering-eleven-algorithms-source-code.html
14 | 15 |
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 16 |
17 |
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 18 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, 20 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 21 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 22 | OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, 23 | OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 25 | THE POSSIBILITY OF SUCH DAMAGE.

26 | 27 |

Sharpen Algorithm

28 | mikecao and jaredatdannyronsrescue
29 | https://gist.github.com/mikecao/65d9fc92dc7197cb8a7c?permalink_comment_id=4314200#gistcomment-4314200
30 | 31 |

Image Scale Algorithm

32 | MIT License - Copyright (c) 2017 Eugene Tiurin
33 | https://github.com/ytiurin/downscale/blob/master/src/downsample.js
34 | 35 |

RotSprite Algorithm for WebGL

36 | MIT License - Copyright (c) 2022 Abdelrahman Adnan Lahrech
37 | https://github.com/adnanlah/rotsprite-webgl/tree/master
38 | 39 |

LZW Decoder Used for decoding GIF files

40 | MIT License - Copyright (c) 2015 Matt Way
41 | https://github.com/matt-way/gifuct-js/blob/master/src/lzw.js
42 | 43 |

FileSaver.js (Used as fallback for older browsers)

44 | MIT License - Copyright © 2016 Eli Grey
45 | https://github.com/eligrey/FileSaver.js
46 |
47 | 48 | 49 | Additional Retro Platform palettes by Zeb Elwood 50 | -------------------------------------------------------------------------------- /_style/_menu.scss: -------------------------------------------------------------------------------- 1 | @import "var"; 2 | 3 | .menu{ 4 | position: absolute; 5 | left: 0; 6 | right: 0; 7 | border: 1px solid black; 8 | height: 25px; 9 | background-color: $panel-background-color; 10 | color: $menu-text-color; 11 | z-index: 1000; 12 | user-select: none; 13 | white-space: nowrap; 14 | 15 | .hamburger{ 16 | display: none; 17 | } 18 | 19 | a.main{ 20 | position: relative; 21 | display: inline-block; 22 | padding: 0 10px; 23 | line-height: 23px; 24 | font-size: 13px; 25 | 26 | .sub{ 27 | left: 0; 28 | margin-top: 0; 29 | position: absolute; 30 | background-color: $panel-background-color; 31 | color: $menu-text-color; 32 | border: 1px solid black; 33 | display: none; 34 | 35 | a{ 36 | display: block; 37 | padding: 0 24px 0 10px; 38 | font-size: 13px; 39 | line-height: 23px; 40 | white-space: nowrap; 41 | position: relative; 42 | 43 | &.wide{ 44 | padding: 0 70px 0 10px; 45 | 46 | &.ultra{ 47 | padding: 0 90px 0 10px; 48 | } 49 | } 50 | 51 | &.caret{ 52 | &:after{ 53 | content: ""; 54 | position: absolute; 55 | right: 2px; 56 | width: 16px; 57 | height: 23px; 58 | background-image: url("../_img/caret.svg"); 59 | background-size: contain; 60 | background-repeat: no-repeat; 61 | background-position: center; 62 | opacity: 0.5; 63 | } 64 | } 65 | 66 | &.checked{ 67 | &:before{ 68 | content: ""; 69 | position: absolute; 70 | left: 4px; 71 | top: 4px; 72 | bottom: 0; 73 | width: 14px; 74 | background-image: url("../_img/check.svg"); 75 | background-size: contain; 76 | background-repeat: no-repeat; 77 | } 78 | } 79 | 80 | &:hover{ 81 | background-color: $panel-background-active; 82 | cursor: pointer; 83 | 84 | .subsub{ 85 | display: block; 86 | } 87 | } 88 | 89 | .shortkey{ 90 | position: absolute; 91 | right: 6px; 92 | top: 1px; 93 | color: $menu-text-color-dim; 94 | font-size: 11px; 95 | } 96 | } 97 | 98 | &.checkable{ 99 | a{ 100 | padding: 0 24px 0 20px; 101 | } 102 | } 103 | 104 | .info{ 105 | position: absolute; 106 | right: 6px; 107 | top: 1px; 108 | color: $menu-text-color-dim; 109 | font-size: 11px; 110 | } 111 | 112 | 113 | .subsub{ 114 | position: absolute; 115 | background-color: $panel-background-color; 116 | border: 1px solid black; 117 | z-index: 100; 118 | top: 0; 119 | left: 50%; 120 | display: none; 121 | 122 | &.checkable{ 123 | a{ 124 | padding: 0 24px 0 20px; 125 | 126 | &.hasinfo{ 127 | padding-right: 120px; 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | &:hover, 135 | &.active{ 136 | background-color: $panel-background-active; 137 | cursor: pointer; 138 | } 139 | 140 | &.active{ 141 | .sub{ 142 | display: block; 143 | } 144 | } 145 | } 146 | } 147 | 148 | body.presentation{ 149 | .menu { 150 | display: none; 151 | } 152 | } -------------------------------------------------------------------------------- /_script/fileformats/detect.js: -------------------------------------------------------------------------------- 1 | import BinaryStream from "../util/binarystream.js"; 2 | import AmigaIcon from "./amigaIcon.js"; 3 | import IFF from "./iff.js"; 4 | import GIF from "./gif.js"; 5 | import PNG from "./png.js"; 6 | 7 | let FileDetector = (function () { 8 | let me = {}; 9 | 10 | me.detect = function (data, name) { 11 | return new Promise((next) => { 12 | name = name || ""; 13 | let ext = name.split(".").pop().toLowerCase(); 14 | let file; 15 | 16 | if (ext === "info") { 17 | file = BinaryStream(data.slice(0, data.byteLength), true); 18 | file.goto(0); 19 | // Note: this can be Async! 20 | AmigaIcon.parse(file, function (icon) { 21 | if (icon) { 22 | let canvas = AmigaIcon.getImage(icon); 23 | let canvas2 = AmigaIcon.getImage(icon, 1); 24 | next({ 25 | image: [canvas, canvas2], 26 | type: AmigaIcon.getType(icon), 27 | }); 28 | } else { 29 | detectIFF(); 30 | } 31 | }); 32 | } else if (ext === "gif"){ 33 | // Note: GIFs are always little-endian 34 | // see https://www.w3.org/Graphics/GIF/spec-gif89a.txt 35 | file = BinaryStream(data.slice(0, data.byteLength), false); 36 | file.goto(0); 37 | let result = GIF.detect(file); 38 | if (result) { 39 | next(GIF.toFrames(file)); 40 | }else{ 41 | next(false); 42 | } 43 | } else if (ext === "png"){ 44 | // check if it's an indexed PNG 45 | // note: PNGs are always big-endian 46 | file = BinaryStream(data.slice(0, data.byteLength), true); 47 | file.goto(0); 48 | let result = PNG.detect(file); 49 | if (result){ 50 | PNG.parse(file).then(next); 51 | }else{ 52 | next(false); 53 | } 54 | } else { 55 | file = BinaryStream(data.slice(0, data.byteLength), true); 56 | file.goto(0); 57 | detectIFF(); 58 | } 59 | 60 | function detectIFF() { 61 | let fileType = IFF.detect(file); 62 | if (fileType) { 63 | let data = IFF.parse(file, true, fileType); 64 | let img; 65 | if (data && data.frames && data.frames.length) { 66 | img = data.frames.map((frame) => { 67 | //TODO: maybe defer rendering all frames until needed? 68 | return IFF.toCanvas(frame); 69 | }); 70 | //img = IFF.toCanvas(data.frames[0]); 71 | }else{ 72 | if (data && data.width) img = IFF.toCanvas(data); 73 | } 74 | if (img) { 75 | next({ 76 | image: img, 77 | type: "IFF", 78 | data: data, 79 | }); 80 | } else { 81 | next(false); 82 | } 83 | } else { 84 | next(false); 85 | } 86 | } 87 | }); 88 | }; 89 | return me; 90 | })(); 91 | 92 | export default FileDetector; 93 | -------------------------------------------------------------------------------- /_script/ui/components/resampleDialog.js: -------------------------------------------------------------------------------- 1 | import $,{$div, $elm, $title} from "../../util/dom.js"; 2 | import ImageFile from "../../image.js"; 3 | import UserSettings from "../../userSettings.js"; 4 | 5 | var ResampleDialog = function() { 6 | let me = {}; 7 | let lockAspectRatio = true; 8 | let aspectRatio = 1; 9 | 10 | me.render = function (container,modal) { 11 | let image = ImageFile.getCurrentFile(); 12 | aspectRatio = image.width/image.height; 13 | container.innerHTML = ""; 14 | let inputW; 15 | let inputH; 16 | let lock; 17 | let qbuttons; 18 | let qualitySelect; 19 | let resampleQuality = UserSettings.get("resampleQuality") || "pixelated"; 20 | 21 | $("h3",{parent:container},"Resize Image to:"); 22 | $(".panel.form",{parent:container}, 23 | $("span.label","width"), 24 | inputW = $("input",{type:"number",value:image.width, onkeydown:modal.inputKeyDown, oninput:()=>{ 25 | if (lockAspectRatio){ 26 | let w = parseInt(inputW.value); 27 | if (isNaN(w)) w=image.width; 28 | inputH.value = Math.round(w/aspectRatio); 29 | }}}), 30 | $("span","pixels"), 31 | $("br"), 32 | $("span.label","height"), 33 | inputH = $("input",{type:"number",value:image.height, onkeydown:modal.inputKeyDown, oninput:()=>{ 34 | if (lockAspectRatio){ 35 | let h = parseInt(inputH.value); 36 | if (isNaN(h)) w=image.height; 37 | inputW.value = Math.round(h*aspectRatio); 38 | }}}), 39 | $("span","pixels"), 40 | lock = $(".lock.active",$(".link",{onClick:()=>{ 41 | lockAspectRatio = !lockAspectRatio; 42 | lock.classList.toggle("active",lockAspectRatio); 43 | }})), 44 | qbuttons = $(".quick"), 45 | qualitySelect = $("select.resize",$("option",{selected:resampleQuality==="pixelated"},"Pixelated"),$("option",{selected:resampleQuality==="smooth"},"Smooth")) 46 | ); 47 | 48 | $(".buttons",{parent:container}, 49 | $(".button.ghost",{onClick:modal.hide},"Cancel"), 50 | $(".button.primary",{onClick:()=>{ 51 | let w = parseInt(inputW.value); 52 | if (isNaN(w)) w = image.width; 53 | let h = parseInt(inputH.value); 54 | if (isNaN(h)) w = image.height; 55 | if (w<1)w=1; 56 | if (h<1)h=1; 57 | let quality = qualitySelect.value === "Pixelated" ? "pixelated" : "smooth"; 58 | UserSettings.set("resampleQuality",quality); 59 | modal.hide(); 60 | ImageFile.resample({width:w,height: h,quality:quality}); 61 | }},"Update") 62 | ); 63 | 64 | 65 | let labels = ["x2","/2","x3","/3"]; 66 | for (let i = 0;i<4;i++){ 67 | $div("button calc",labels[i],qbuttons,()=>{ 68 | let w = parseInt(inputW.value); 69 | let h = parseInt(inputH.value); 70 | if (isNaN(w)) w=image.width; 71 | if (isNaN(h)) w=image.height; 72 | if (i===0){w*=2;h*=2;} 73 | if (i===1){w/=2;h/=2;} 74 | if (i===2){w*=3;h*=3;} 75 | if (i===3){w/=3;h/=3;} 76 | inputW.value = Math.round(w); 77 | inputH.value = Math.round(h); 78 | }); 79 | } 80 | 81 | } 82 | return me; 83 | }(); 84 | 85 | export default ResampleDialog; -------------------------------------------------------------------------------- /_script/ui/components/paletteList.js: -------------------------------------------------------------------------------- 1 | import $ from "../../util/dom.js"; 2 | import Palette from "../palette.js"; 3 | import Color from "../../util/color.js"; 4 | import Eventbus from "../../util/eventbus.js"; 5 | import {COMMAND, EVENT} from "../../enum.js"; 6 | let PaletteList = function(){ 7 | let me = {}; 8 | let container; 9 | let parent; 10 | let inner; 11 | let showGeneral = true; 12 | let showPlatform = false; 13 | 14 | me.init = function(_parent){ 15 | parent = _parent; 16 | Eventbus.on(COMMAND.TOGGLEPALETTES,me.toggle); 17 | } 18 | 19 | me.toggle = function(){ 20 | if (document.body.classList.contains("withpalettelist")){ 21 | me.hide(); 22 | }else{ 23 | me.show(); 24 | } 25 | } 26 | 27 | me.show = ()=>{ 28 | if (!container) generate(); 29 | document.body.classList.add("withpalettelist"); 30 | container.classList.add("active"); 31 | } 32 | 33 | me.hide = ()=>{ 34 | document.body.classList.remove("withpalettelist"); 35 | container.classList.remove("active"); 36 | } 37 | 38 | function generate(){ 39 | if (!container){ 40 | container = $(".palettelist",$(".caption","Palettes", 41 | $(".close",{ 42 | onClick:me.hide 43 | },"x") 44 | ),inner = $(".inner")); 45 | parent.appendChild(container); 46 | } 47 | inner.innerHTML = ""; 48 | 49 | let list = Palette.getPaletteMap(); 50 | 51 | inner.appendChild($(".group" + (showGeneral?".active":""),{onClick:()=>{showGeneral=!showGeneral; generate()}},"General")); 52 | 53 | if (showGeneral){ 54 | for (let key in list){ 55 | if (!list[key].platform) renderPalette(list[key]); 56 | } 57 | } 58 | 59 | inner.appendChild($(".group" + (showPlatform?".active":""),{onClick:()=>{showPlatform=!showPlatform; generate()}},"Retro platforms")); 60 | if (showPlatform){ 61 | for (let key in list){ 62 | if (list[key].platform) renderPalette(list[key]); 63 | } 64 | } 65 | 66 | inner.appendChild($(".buttons", 67 | $(".button",{onClick:()=>{Eventbus.trigger(COMMAND.LOADPALETTE)}}, "Load Palette"), 68 | $(".button",{onClick:()=>{Eventbus.trigger(COMMAND.SAVEPALETTE)}}, "Save Palette") 69 | )); 70 | } 71 | 72 | function renderPalette(palette){ 73 | let preset = palette.palette; 74 | if (preset){ 75 | let canvas,ctx,colors; 76 | $(".palette", 77 | {parent:inner,onClick:()=>{ 78 | Palette.set(colors); 79 | Eventbus.trigger(EVENT.paletteChanged); 80 | }}, 81 | $(".caption",palette.label), 82 | canvas = $("canvas") 83 | ); 84 | 85 | Palette.loadPreset(palette).then(_colors=>{ 86 | colors = _colors; 87 | canvas.width = 80; 88 | let size = colors.length>32 ? 5:10; 89 | let colorsPerRow = Math.floor(canvas.width/size); 90 | canvas.height = Math.ceil(colors.length/colorsPerRow) * size; 91 | ctx = canvas.getContext("2d"); 92 | colors.forEach((color,index)=>{ 93 | ctx.fillStyle = Color.toString(color); 94 | ctx.fillRect((index%colorsPerRow)*size,Math.floor(index/colorsPerRow)*size,size,size); 95 | }); 96 | }); 97 | } 98 | } 99 | 100 | return me; 101 | }(); 102 | 103 | export default PaletteList; --------------------------------------------------------------------------------