about summary refs log blame commit diff stats
path: root/js/pixel-art/app.js
blob: 9e501fe7077d68c2b0346b445cd0c8f66ab32b5e (plain) (tree)


























                                                                                                                            


                                     



                         


                                        






                                                                     




                     











                                                                             


                                              









                                                                


                                                             














                                                                           


                                        




                                                                 


                                                    

















                                                                   


                   























                                                                          


                  















                                                                          


                   














                                                                          


                                     














                                                            


                                            




















                                                              

 
















































                                                                           


                                     


                                                        


                  



















                                                     


                  







                                                          


                              






                                                                    


                                     










                                                                


                                  




















                                                            


                                            




















                                                                        



                  












                                                           


                    




                                                  

  





                   

                      
                                                                   

  














                                                                  
// Inspired by https://eloquentjavascript.net/19_paint.html

class Picture { // Honestly? I hate classes in javascript, if I were doing this not from a book I wouldn't do this like this
	constructor(width, height, pixels) {
		this.width = width;
		this.height = height;
		this.pixels = pixels;
	}
	static empty(width, height, color) {
		let pixels = new Array(width * height).fill(color);
		return new Picture(width, height, pixels);
	}
	pixel(x, y) {
		return this.pixels[x + y * this.width];
	}
	draw(pixels) {
		let copy = this.pixels.slice();
		for (let {
				x,
				y,
				color
			}
			of pixels) {
			copy[x + y * this.width] = color;
		}
		return new Picture(this.width, this.height, copy);
	}
}

function updateState(state, action) {
	return {
		...state,
		...action
	};
}

function elt(type, props, ...children) {
	let dom = document.createElement(type);
	if (props) Object.assign(dom, props);
	for (let child of children) {
		if (typeof child != "string") dom.appendChild(child);
		else dom.appendChild(document.createTextNode(child));
	}
	return dom;
}

const scale = 10;

class PictureCanvas {
	constructor(picture, pointerDown) {
		this.dom = elt("canvas", {
			onmousedown: event => this.mouse(event, pointerDown),
			ontouchstart: event => this.touch(event, pointerDown)
		});
		this.syncState(picture);
	}
	syncState(picture) {
		if (this.picture == picture) return;
		this.picture = picture;
		drawPicture(this.picture, this.dom, scale);
	}
}

function drawPicture(picture, canvas, scale) {
	canvas.width = picture.width * scale;
	canvas.height = picture.height * scale;
	let cx = canvas.getContext("2d");

	picture.pixels.forEach((color, index) => {
		let x = index % picture.width;
		let y = Math.floor(index / picture.width);
		cx.fillStyle = color;
		cx.fillRect(x * scale, y * scale, scale, scale);
	});
}

PictureCanvas.prototype.mouse = function(downEvent, onDown) {
	if (downEvent.button != 0) return;
	let pos = pointerPosition(downEvent, this.dom);
	let onMove = onDown(pos);
	if (!onMove) return;
	let move = moveEvent => {
		if (moveEvent.buttons == 0) {
			this.dom.removeEventListener("mousemove", move);
		} else {
			let newPos = pointerPosition(moveEvent, this.dom);
			if (newPos.x == pos.x && newPos.y == pos.y) return;
			pos = newPos;
			onMove(newPos);
		}
	};
	this.dom.addEventListener("mousemove", move);
};

function pointerPosition(pos, domNode) {
	let rect = domNode.getBoundingClientRect();
	return {
		x: Math.floor((pos.clientX - rect.left) / scale),
		y: Math.floor((pos.clientY - rect.top) / scale)
	};
}

PictureCanvas.prototype.touch = function(startEvent,
	onDown) {
	let pos = pointerPosition(startEvent.touches[0], this.dom);
	let onMove = onDown(pos);
	startEvent.preventDefault();
	if (!onMove) return;
	let move = moveEvent => {
		let newPos = pointerPosition(moveEvent.touches[0],
			this.dom);
		if (newPos.x == pos.x && newPos.y == pos.y) return;
		pos = newPos;
		onMove(newPos);
	};
	let end = () => {
		this.dom.removeEventListener("touchmove", move);
		this.dom.removeEventListener("touchend", end);
	};
	this.dom.addEventListener("touchmove", move);
	this.dom.addEventListener("touchend", end);
};

class PixelEditor {
	constructor(state, config) {
		let {
			tools,
			controls,
			dispatch
		} = config;
		this.state = state;

		this.canvas = new PictureCanvas(state.picture, pos => {
			let tool = tools[this.state.tool];
			let onMove = tool(pos, this.state, dispatch);
			if (onMove) return pos => onMove(pos, this.state);
		});
		this.controls = controls.map(
			Control => new Control(state, config));
		this.dom = elt("div", {}, this.canvas.dom, elt("br"),
			...this.controls.reduce(
				(a, c) => a.concat(" ", c.dom), []));
	}
	syncState(state) {
		this.state = state;
		this.canvas.syncState(state.picture);
		for (let ctrl of this.controls) ctrl.syncState(state);
	}
}

class ToolSelect {
	constructor(state, {
		tools,
		dispatch
	}) {
		this.select = elt("select", {
			onchange: () => dispatch({
				tool: this.select.value
			})
		}, ...Object.keys(tools).map(name => elt("option", {
			selected: name == state.tool
		}, name)));
		this.dom = elt("label", null, "🖌 Tool: ", this.select);
	}
	syncState(state) {
		this.select.value = state.tool;
	}
}

class ColorSelect {
	constructor(state, {
		dispatch
	}) {
		this.input = elt("input", {
			type: "color",
			value: state.color,
			onchange: () => dispatch({
				color: this.input.value
			})
		});
		this.dom = elt("label", null, "🎨 Color: ", this.input);
	}
	syncState(state) {
		this.input.value = state.color;
	}
}

function draw(pos, state, dispatch) {
	function drawPixel({
		x,
		y
	}, state) {
		let drawn = {
			x,
			y,
			color: state.color
		};
		dispatch({
			picture: state.picture.draw([drawn])
		});
	}
	drawPixel(pos, state);
	return drawPixel;
}

function rectangle(start, state, dispatch) {
	function drawRectangle(pos) {
		let xStart = Math.min(start.x, pos.x);
		let yStart = Math.min(start.y, pos.y);
		let xEnd = Math.max(start.x, pos.x);
		let yEnd = Math.max(start.y, pos.y);
		let drawn = [];
		for (let y = yStart; y <= yEnd; y++) {
			for (let x = xStart; x <= xEnd; x++) {
				drawn.push({
					x,
					y,
					color: state.color
				});
			}
		}
		dispatch({
			picture: state.picture.draw(drawn)
		});
	}
	drawRectangle(start);
	return drawRectangle;
}

const around = [{
		dx: -1,
		dy: 0
	}, {
		dx: 1,
		dy: 0
	},
	{
		dx: 0,
		dy: -1
	}, {
		dx: 0,
		dy: 1
	}
];

function fill({
	x,
	y
}, state, dispatch) {
	let targetColor = state.picture.pixel(x, y);
	let drawn = [{
		x,
		y,
		color: state.color
	}];
	for (let done = 0; done < drawn.length; done++) {
		for (let {
				dx,
				dy
			}
			of around) {
			let x = drawn[done].x + dx,
				y = drawn[done].y + dy;
			if (x >= 0 && x < state.picture.width &&
				y >= 0 && y < state.picture.height &&
				state.picture.pixel(x, y) == targetColor &&
				!drawn.some(p => p.x == x && p.y == y)) {
				drawn.push({
					x,
					y,
					color: state.color
				});
			}
		}
	}
	dispatch({
		picture: state.picture.draw(drawn)
	});
}

function pick(pos, state, dispatch) {
	dispatch({
		color: state.picture.pixel(pos.x, pos.y)
	});
}

class SaveButton {
	constructor(state) {
		this.picture = state.picture;
		this.dom = elt("button", {
			onclick: () => this.save()
		}, "💾 Save");
	}
	save() {
		let canvas = elt("canvas");
		drawPicture(this.picture, canvas, 1);
		let link = elt("a", {
			href: canvas.toDataURL(),
			download: "pixelart.png"
		});
		document.body.appendChild(link);
		link.click();
		link.remove();
	}
	syncState(state) {
		this.picture = state.picture;
	}
}

class LoadButton {
	constructor(_, {
		dispatch
	}) {
		this.dom = elt("button", {
			onclick: () => startLoad(dispatch)
		}, "📁 Load");
	}
	syncState() {}
}

function startLoad(dispatch) {
	let input = elt("input", {
		type: "file",
		onchange: () => finishLoad(input.files[0], dispatch)
	});
	document.body.appendChild(input);
	input.click();
	input.remove();
}

function finishLoad(file, dispatch) {
	if (file == null) return;
	let reader = new FileReader();
	reader.addEventListener("load", () => {
		let image = elt("img", {
			onload: () => dispatch({
				picture: pictureFromImage(image)
			}),
			src: reader.result
		});
	});
	reader.readAsDataURL(file);
}

function pictureFromImage(image) {
	let width = Math.min(100, image.width);
	let height = Math.min(100, image.height);
	let canvas = elt("canvas", {
		width,
		height
	});
	let cx = canvas.getContext("2d");
	cx.drawImage(image, 0, 0);
	let pixels = [];
	let {
		data
	} = cx.getImageData(0, 0, width, height);

	function hex(n) {
		return n.toString(16).padStart(2, "0");
	}
	for (let i = 0; i < data.length; i += 4) {
		let [r, g, b] = data.slice(i, i + 3);
		pixels.push("#" + hex(r) + hex(g) + hex(b));
	}
	return new Picture(width, height, pixels);
}

function historyUpdateState(state, action) {
	if (action.undo == true) {
		if (state.done.length == 0) return state;
		return {
			...state,
			picture: state.done[0],
			done: state.done.slice(1),
			doneAt: 0
		};
	} else if (action.picture && state.doneAt < Date.now() - 1000) {
		return {
			...state,
			...action,
			done: [state.picture, ...state.done],
			doneAt: Date.now()
		};
	} else {
		return {
			...state,
			...action
		};
	}
}


class UndoButton {
	constructor(state, {
		dispatch
	}) {
		this.dom = elt("button", {
			onclick: () => dispatch({
				undo: true
			}),
			disabled: state.done.length == 0
		}, "⮪ Undo");
	}
	syncState(state) {
		this.dom.disabled = state.done.length == 0;
	}
}

const startState = {
	tool: "draw",
	color: "#000000",
	picture: Picture.empty(16, 16, "#f0f0f0"),
	done: [],
	doneAt: 0
};

const baseTools = {
	draw,
	fill,
	rectangle,
	pick
};

const baseControls = [
	ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton
];

function startPixelEditor({
	state = startState,
	tools = baseTools,
	controls = baseControls
}) {
	let app = new PixelEditor(state, {
		tools,
		controls,
		dispatch(action) {
			state = historyUpdateState(state, action);
			app.syncState(state);
		}
	});
	return app.dom;
}