├── 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 |
22 | the pixel approach of Deluxe Paint
23 | the modern features of today's image editors
24 | the platform independent nature of browser based applications.
25 |
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 |
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 |
15 | Layers
16 | Selections
17 | Masks
18 | Effects and filters
19 | Multiple undo/redo
20 | Copy/Paste from any other image program or image source
21 | Super fine color reduction tools
22 | Customizable dither tools
23 | And much more ...
24 |
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 | VIDEO
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;
--------------------------------------------------------------------------------