Home

Awesome

JS Paint

A pixel-perfect web-based MS Paint remake and more... Try it out! Then join the Discord server to share your art!

JS Paint recreates every tool and menu of MS Paint, and even little-known features, to a high degree of fidelity.

It supports themes, additional file types, and accessibility features like a Dwell Clicker and Speech Recognition.

Screenshot

Ah yes, good old Paint. Not the one with the ribbons or the new skeuomorphic one with the interface that can take up nearly half the screen. (And not the even newer Paint 3D.)

Windows 95, 98, and XP were the golden years of Paint. You had a tool box and a color box, a foreground color and a background color, and that was all you needed.

Things were simple.

But we want to undo more than three actions. We want to edit transparent images. We can't just keep using the old Paint.

So that's why I'm making JS Paint. I want to bring good old Paint into the modern era.

Current improvements include:

Editing Features:

Miscellaneous Improvements:

<!-- Half-features: * When you do **Edit > Paste From...** you can select transparent images. ~~You can even paste a transparent animated GIF and then hold <kbd>Shift</kbd> while dragging the selection to smear it across the canvas *while it animates*!~~ Update: This was [due to not-to-spec behavior in Chrome.](https://christianheilmann.com/2014/04/16/browser-inconsistencies-animated-gif-and-drawimage/) I may reimplement this in the future as I really liked this feature. * You can open SVG files, though only as a bitmap. (Note: it may open super large, or tiny. There's no option to choose a size when opening.) -->

JS Paint drawing of JS Paint on a phone

Limitations:

A few things with the tools aren't done yet. See TODO.md

Full clipboard support in the web app requires a browser supporting the Async Clipboard API w/ Images, namely Chrome 76+ at the time of writing.

In other browsers you can still copy with <kbd>Ctrl+C</kbd>, cut with <kbd>Ctrl+X</kbd>, and paste with <kbd>Ctrl+V</kbd>, but data copied from JS Paint can only be pasted into other instances of JS Paint. External images can be pasted in.

Supported File Formats

Image Formats

⚠️ Saving as JPEG will introduce artifacts that cause problems when using the Fill tool or transparent selections.

⚠️ Saving in some formats will reduce the number of colors in the image.

💡 Unlike in MS Paint, you can use Edit > Undo to revert color or quality reduction from saving. This doesn't undo saving the file, but allows you to then save in a different format with higher quality, using File > Save As.

💡 Saving as PNG is recommended as it gives small file sizes while retaining full quality.

File ExtensionNameReadWriteRead PaletteWrite Palette
.pngPNG🔜
.bmp, .dibMonochrome Bitmap🔜
.bmp, .dib16 Color Bitmap🔜
.bmp, .dib256 Color Bitmap🔜
.bmp, .dib24-bit BitmapN/AN/A
.tif, .tiff, .dng, .cr2, .nefTIFF (loads first page)
.pdfPDF (loads first page)
.webpWebP🌐🌐
.gifGIF🌐🌐
.jpeg, .jpgJPEG🌐🌐N/AN/A
.svgSVG (only default size)🌐
.icoICO (only default size)🌐

Capabilities marked with 🌐 are currently left up to the browser to support or not. If "Write" is marked with 🌐, the format will appear in the file type dropdown but may not work when you try to save. For opening files, see Wikipedia's browser image format support table for more information.

Capabilities marked with 🔜 may be coming soon, and N/A means not applicable.

"Read Palette" refers to loading the colors into the Colors box automatically (from an indexed color image), and "Write Palette" refers to writing an indexed color image.

Color Palette Formats

With Colors > Save Colors and Colors > Get Colors you can save and load colors in many different formats, for compatibility with a wide range of programs.

If you want to add extensive palette support to another application, I've made this functionality available as a library: <img src="images/anypalette-logo-128x128.png" height="16"> AnyPalette.js

File ExtensionNameProgramsReadWrite
.palRIFF PaletteMS Paint for Windows 95 and Windows NT 4.0
.gplGIMP PaletteGimp, Inkscape, Krita, KolourPaint, Scribus, CinePaint, MyPaint
.acoAdobe Color SwatchAdobe Photoshop
.aseAdobe Swatch ExchangeAdobe Photoshop, InDesign, and Illustrator
.txtPaint.NET PalettePaint.NET
.actAdobe Color TableAdobe Photoshop and Illustrator
.pal, .psppalettePaint Shop Pro PalettePaint Shop Pro (Jasc Software / Corel)
.hplHomesite PaletteAllaire Homesite / Macromedia ColdFusion
.csColorSchemerColorSchemer Studio
.palStarCraft PaletteStarCraft
.wpeStarCraft Terrain PaletteStarCraft
.sketchpaletteSketch PaletteSketch
.splSkencil PaletteSkencil (formerly called Sketch)
.socStarOffice ColorsStarOffice, OpenOffice, LibreOffice
.colorsKolourPaint Color CollectionKolourPaint
.colorsPlasma Desktop Color SchemeKDE Plasma Desktop
.themeWindows ThemeWindows Desktop
.themepackWindows ThemeWindows Desktop
.css, .scss, .stylCascading StyleSheetsWeb browsers / web pages
.html, .svg, .jsany text files with CSS colorsWeb browsers / web pages

Did you know?

Desktop App

PWA

JS Paint can be installed as a Progressive Web App (PWA), although it doesn't work offline yet. Look for the install prompt in the address bar.

PWA features:

Missing features:

Electron

I've also built it into a desktop app with Electron and Electron Forge. You can download it from the releases page.

JS Paint running as a desktop app on macOS

Electron app features:

<details><summary>Electron app limitations</summary> </details>

Development Setup

Clone the repo.

Install Node.js if you don't have it, then open up a command prompt / terminal in the project directory.

Quality Assurance

Run npm run lint to check for spelling errors, type errors, code style issues, and other problems.

Run npm run format to automatically fix formatting issues, or npx eslint --fix to fix all auto-fixable issues.

The formatting rules are configured for compatibility with VS Code's built-in formatter.

Run npm test to run browser-based tests with Cypress. (It's slow to start up and run tests, unfortunately.)

Run npm run accept to accept any visual changes. This unfortunately re-runs all the tests, rather than accepting results of the previous test, so you could end up with different results than the previous test. If you use GitHub Desktop, you can view diffs of images, in four different modes.

To open the Cypress UI, first run npm run test:start-server, then concurrently npm run cy:open

Tests are also run in continuous integration with Travis CI.

Web App (https://jspaint.app)

After you've installed dependencies with npm i, use npm run dev to start a live-reloading server.

Make sure any layout-important styles go in layout.css. When updating layout.css, a right-to-left version of the stylesheet is generated, using RTLCSS.
You should test the RTL layout by changing the language to Arabic or Hebrew. Go to Extras > Language > العربية or עברית.
See Control Directives for how to control the RTL layout.

There is a VS Code launch task for attaching to Chrome for debugging. See .vscode/launch.json for usage instructions.

Desktop App (Electron)

electron-debug is included, so you can use <kbd>F5</kbd>/<kbd>Ctrl+R</kbd> to reload and <kbd>F12</kbd>/<kbd>Ctrl+Shift+I</kbd> to open the devtools.

You can build for production with npm run electron:make

There is a VS Code launch task for debugging the Electron main process. For the renderer process, you can use the embedded Chrome DevTools.

Deployment

JS Paint can be deployed using a regular web server.

Nothing needs to be compiled.

CORS proxy

Optionally, you can set up a CORS Anywhere server, for loading images from the web, if you paste a URL into JS Paint, or use the #load:<URL> feature with images that are not on the same domain.

By default it will use a CORS Anywhere instance set up to work with jspaint.app.

It is hosted for free on Heroku, and you can set up your own instance and configure it to work with your own domain.

You'll have to find and replace https://jspaint-cors-proxy.herokuapp.com with your own instance URL.

Multiplayer Support

Multiplayer support currently relies on Firebase, which is not open source software.

You could create a Firebase Realtime Database instance and edit JS Paint's sessions.js to point to it, replacing the config passed to initializeApp with the config from the Firebase Console when you set up a Web App.

But the multiplayer mode is very shoddy so far. It should be replaced with something open source, more secure, more efficient, and more robust.

Embed in your website

Simple

Add this to your HTML:

<iframe src="https://jspaint.app" width="100%" height="100%"></iframe>

Start with an image

You can have it load an image from a URL by adding #load:<URL> to the URL.

<iframe src="https://jspaint.app#load:https://jspaint.app/favicon.ico" width="100%" height="100%"></iframe>

Advanced

If you want to control JS Paint, how it saves/loads files, or access the canvas directly, there is an unstable API.

First you need to clone the repo, so you can point an iframe to your local copy.

The local copy of JS Paint has to be hosted on the same web server as the containing page, or more specifically, it has to share the same origin.

Having a local copy also means things won't break any time the API changes.

If JS Paint is cloned to a folder called jspaint, which lives in the same folder as the page you want to embed it in, you can use this:

<iframe src="jspaint/index.html" id="jspaint-iframe" width="100%" height="100%"></iframe>

If it lives somewhere else, you may need to add ../ to the start of the path, to go up a level. For example, src="../../apps/jspaint/index.html". You can also use an absolute URL, like src="https://example.com/cool-apps/jspaint/index.html".

Changing how files are saved/loaded

You can override the file saving and opening dialogs with JS Paint's systemHooks API.

<script>
var iframe = document.getElementById("jspaint-iframe");
var jspaint = iframe.contentWindow;
// Wait for systemHooks object to exist (the iframe needs to load)
waitUntil(()=> jspaint.systemHooks, 500, ()=> {
	// Hook in
	jspaint.systemHooks.showSaveFileDialog = async ({ formats, defaultFileName, defaultPath, defaultFileFormatID, getBlob, savedCallbackUnreliable, dialogTitle }) => { ... };
	jspaint.systemHooks.showOpenFileDialog = async ({ formats }) => { ... };
	jspaint.systemHooks.writeBlobToHandle = async (save_file_handle, blob) => { ... };
	jspaint.systemHooks.readBlobFromHandle = async (file_handle) => { ... };
});
// General function to wait for a condition to be met, checking at regular intervals
function waitUntil(test, interval, callback) {
	if (test()) {
		callback();
	} else {
		setTimeout(waitUntil, interval, test, interval, callback);
	}
}
</script>

A Blob represents the contents of a file in memory.

A file handle is anything that can identify a file. You get to own this concept, and define how to identify files. It could be anything from an index into an array, to a Dropbox file ID, to an IPFS URL, to a file path. It can be any type, or maybe it needs to be a string, I forget.

Once you have a concept of a file handle, you can implement file pickers using the system hooks, and functions to read and write files.

CommandHooks Used
File > Save AssystemHooks.showSaveFileDialog, then when a file is picked, systemHooks.writeBlobToHandle
File > OpensystemHooks.showOpenFileDialog, then when a file is picked, systemHooks.readBlobFromHandle
File > SavesystemHooks.writeBlobToHandle (or same as File > Save As if there's no file open yet)
Edit > Copy TosystemHooks.showSaveFileDialog, then when a file is picked, systemHooks.writeBlobToHandle
Edit > Paste FromsystemHooks.showOpenFileDialog, then when a file is picked, systemHooks.readBlobFromHandle
File > Set As Wallpaper (Tiled)systemHooks.setWallpaperTiled if defined, else systemHooks.setWallpaperCentered if defined, else same as File > Save As
File > Set As Wallpaper (Centered)systemHooks.setWallpaperCentered if defined, else same as File > Save As
Extras > Render History As GIFSame as File > Save As
Colors > Save ColorsSame as File > Save As
Colors > Get ColorsSame as File > Open

Loading a file initially

To start the app with a file loaded for editing, wait for the app to load, then call systemHooks.readBlobFromHandle with a file handle, and tell the app to load that file blob.

const file_handle = "initial-file-to-load";
systemHooks.readBlobFromHandle(file_handle).then(file => {
	if (file) {
		contentWindow.open_from_file(file, file_handle);
	}
}, (error) => {
	// Note: in some cases, this handler may not be called, and instead an error message is shown by readBlobFromHandle directly.
	contentWindow.show_error_message(`Failed to open file ${file_handle}`, error);
});

This is clumsy, and in the future there may be a query string parameter to load an initial file by its handle. (Note to self: it will need to wait for your system hooks to be registered, somehow.)

There's already a query string parameter to load from a URL:

<iframe src="https://jspaint.app?load:SOME_URL_HERE"></iframe>

But this won't set up the file handle for saving.

Integrating Set as Wallpaper

You can define two functions to set the wallpaper, which will be used by File > Set As Wallpaper (Tiled) and File > Set As Wallpaper (Centered).

If you define only systemHooks.setWallpaperCentered, JS Paint will attempt to guess your screen's dimensions and tile the image, applying it by calling your systemHooks.setWallpaperCentered function.

If you don't specify systemHooks.setWallpaperCentered, JS Paint will default to saving a file (<original file name> wallpaper.png) using systemHooks.showSaveFileDialog and systemHooks.writeBlobToHandle.

Here's a full example supporting a persistent custom wallpaper as a background on the containing page:

const wallpaper = document.querySelector("body"); // or some other element

jspaint.systemHooks.setWallpaperCentered = (canvas) => {
	canvas.toBlob((blob) => {
		setDesktopWallpaper(blob, "no-repeat", true);
	});
};
jspaint.systemHooks.setWallpaperTiled = (canvas) => {
	canvas.toBlob((blob) => {
		setDesktopWallpaper(blob, "repeat", true);
	});
};

function setDesktopWallpaper(file, repeat, saveToLocalStorage) {
	const blob_url = URL.createObjectURL(file);
	wallpaper.style.backgroundImage = `url(${blob_url})`;
	wallpaper.style.backgroundRepeat = repeat;
	wallpaper.style.backgroundPosition = "center";
	wallpaper.style.backgroundSize = "auto";
	if (saveToLocalStorage) {
		const fileReader = new FileReader();
		fileReader.onload = () => {
			localStorage.setItem("wallpaper-data-url", fileReader.result);
			localStorage.setItem("wallpaper-repeat", repeat);
		};
		fileReader.onerror = () => {
			console.error("Error reading file (for setting wallpaper)", file);
		};
		fileReader.readAsDataURL(file);
	}
}

// Initialize the wallpaper from localStorage, if it exists
try {
	const wallpaper_data_url = localStorage.getItem("wallpaper-data-url");
	const wallpaper_repeat = localStorage.getItem("wallpaper-repeat");
	if (wallpaper_data_url) {
		fetch(wallpaper_data_url).then(response => response.blob()).then(file => {
			setDesktopWallpaper(file, wallpaper_repeat, false);
		});
	}
} catch (error) {
	console.error(error);
}

It's a little bit recursive, sorry; it could probably be done simpler. Like by just using data URLs. (Actually, I think I wanted to use blob URLs just so that it doesn't bloat the DOM inspector with a super long URL. Which is really a devtools UX bug. Maybe they've improved this?)

Specifying the canvas size

You can load a file that has the desired dimensions. There's no special API for this at the moment.

See Loading a file initially.

Specifying the theme

You could change the theme programmatically:

var iframe = document.getElementById("jspaint-iframe");
var jspaint = iframe.contentWindow;
jspaint.set_theme("modern.css");

but this will break the user preference.

The Extras > Themes menu will still work, but the preference won't persist when reloading the page.

In the future there may be a query string parameter to specify the default theme. You could also fork jspaint to change the default theme.

Specifying the language

Similar to the theme, you can try to change the language programmatically:

var iframe = document.getElementById("jspaint-iframe");
var jspaint = iframe.contentWindow;
jspaint.set_language("ar");

but this will actually ask the user to reload the application to change languages.

The Extras > Language menu will still work, but the user will be bothered to change the language every time they reload the page.

In the future there may be a query string parameter to specify the default language. You could also fork jspaint to change the default language.

Adding custom menus

Not supported yet. You could fork jspaint and add your own menus.

Accessing the canvas directly

With access to the canvas, you can implement a live preview of your drawing, for example updating a texture in a game engine in realtime.

var iframe = document.getElementById("jspaint-iframe");
// contentDocument here refers to the webpage loaded in the iframe, not the image document loaded in jspaint.
// We're just reaching inside the iframe to get the canvas.
var canvas = iframe.contentDocument.querySelector(".main-canvas");

It's recommended not to use this for loading a document, as it won't change the document title, or reset undo/redo history, among other things. Instead use open_from_file.

Performing custom actions

If you want to make buttons or other UI to do things to the document, you should (probably) make it undoable. It's very easy, just wrap your action in a call to undoable.

var iframe = document.getElementById("jspaint-iframe");
var jspaint = iframe.contentWindow;
var icon = new Image();
icon.src = "some-folder/some-image-15x11-pixels.png";
jspaint.undoable({
	name: "Seam Carve",
	icon: icon, // optional
}, function() {
	// do something to the canvas
});

<a href="#systemHooks.showSaveFileDialog" id="systemHooks.showSaveFileDialog">async function systemHooks.showSaveFileDialog({ formats, defaultFileName, defaultPath, defaultFileFormatID, getBlob, savedCallbackUnreliable, dialogTitle })</a>

Define this function to override the default save dialog. This is used both for saving images, as well as palette files, and animations.

Arguments:

Note the inversion of control here: JS Paint calls your systemHooks.showSaveFileDialog function, and then you call JS Paint's getBlob function. Once getBlob resolves, you can call the savedCallbackUnreliable function which is defined by JS Paint. (Hopefully I can clarify this in the future.)

Also note that this function is responsible for saving the file, not just picking a save location. You may reuse your systemHooks.writeBlobToHandle function if it's helpful.

<a href="#systemHooks.showOpenFileDialog" id="systemHooks.showOpenFileDialog">async function systemHooks.showOpenFileDialog({ formats })</a>

Define this function to override the default open dialog. This is used for opening images and palettes.

Arguments:

Note that this function is responsible for loading the contents of the file, not just picking a file. You may reuse your systemHooks.readBlobFromHandle function if it's helpful.

<a href="#systemHooks.writeBlobToHandle" id="systemHooks.writeBlobToHandle">async function systemHooks.writeBlobToHandle(fileHandle, blob)</a>

Define this function to tell JS Paint how to save a file.

Arguments:

Returns:

<a href="#systemHooks.readBlobFromHandle" id="systemHooks.readBlobFromHandle">async function systemHooks.readBlobFromHandle(fileHandle)</a>

Define this function to tell JS Paint how to load a file.

Arguments:

<a href="#systemHooks.setWallpaperTiled" id="systemHooks.setWallpaperTiled">function systemHooks.setWallpaperTiled(canvas)</a>

Define this function to tell JS Paint how to set the wallpaper. See Integrating Set as Wallpaper for an example.

Arguments:

<a href="#systemHooks.setWallpaperCentered" id="systemHooks.setWallpaperCentered">function systemHooks.setWallpaperCentered(canvas)</a>

Define this function to tell JS Paint how to set the wallpaper. See Integrating Set as Wallpaper for an example.

Arguments:

<a href="#undoable" id="undoable">function undoable({ name, icon }, actionFunction)</a>

Use this to make an action undoable.

This function takes a snapshot of the canvas, and some other state, and then calls the actionFunction function. It creates an entry in the history so it can be undone.

Arguments:

<a href="#show_error_message" id="show_error_message">function show_error_message(message, [error])</a>

Use this to show an error message dialog box, optionally with expandable error details.

Arguments:

<a href="#open_from_file" id="open_from_file">function open_from_file(blob, source_file_handle)</a>

Use this to load a file into the app.

Arguments:

Sorry for the quirky API. The API is new, and parts of it have not been designed at all. This was just a hack that I came to depend on, reaching into the internals of JS Paint to load a file. I decided to document it as the first version of the API, since I'll want a changelog when upgrading my usage of it anyways.

<a href="#set_theme" id="set_theme">function set_theme(theme_file_name)</a>

Use this to change the look of the application.

Arguments:

<a href="#set_language" id="set_language">function set_language(language_code)</a>

You can kind of use this to change the language of the application. But actually it will show a prompt to the user to change the language, because the application needs to reload to apply the change. And if that dialog isn't in the right language, well, they'll probably be confused.

Arguments:

Changelog

The API will change a lot, but changes will be documented in the Changelog.

Not just a history of changes, but a migration/upgrading guide. <!-- These are some Ctrl+F keywords. -->

For general project news, click Extras > Project News in the app.

License

JS Paint is free and open source software, licensed under the permissive MIT license.

License GitHub Repo stars GitHub forks