Skip to content

Instantly share code, notes, and snippets.

@marijnh
Last active April 28, 2024 00:34
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save marijnh/55066f9b1cca274e18e27307377da20e to your computer and use it in GitHub Desktop.
Save marijnh/55066f9b1cca274e18e27307377da20e to your computer and use it in GitHub Desktop.
/*
# Clean UI programming in a vacuum
This app was written for Chapter 19 in the 3rd edition of Eloquent
JavaScript—it aims to demonstrate modern UI programming without
depending on a specific framework or library.
Its convention is that components have an interface like this:
```
{
constructor(state: Object, dispatch: fn(Object))
dom: Node,
setState(state: Object)
}
```
Leaf components may replace `dispatch` with another type of value or
set of options (for example callbacks).
The constructor creates the DOM for the component, and attaches event
handlers that may call `dispatch` to initiate an application state
update. At the top level of the app, a state update function computes
a new state from the old state and the value given to `dispatch`, and
calls `setState` on the top-level component with that new state.
Components' `setState` method updates their DOM to reflect the current
state, and recursively calls `setState` on any child components.
Defining such components manually is more work than doing so in a
modern framework, but isn't _too_ awful, and enough for a simple app
like this.
*/
class Picture {
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];
}
setPixel(x, y, color) {
this.pixels[x + y * this.width] = color;
}
copy() {
return new Picture(this.width, this.height, this.pixels.slice());
}
}
function addChildren(dom, children) {
for (let child of children) {
if (typeof child == "string") {
child = document.createTextNode(child);
}
dom.appendChild(child);
}
return dom;
}
function elt(type, attrs, ...children) {
let dom = document.createElement(type);
if (attrs) {
for (let [name, value] of Object.entries(attrs)) {
if (typeof value == "function") dom[name] = value;
else dom.setAttribute(name, value);
}
}
return addChildren(dom, children);
}
function frag(...children) {
return addChildren(document.createDocumentFragment(),
children);
}
const scale = 10;
function mousePosition(event, rect) {
return {x: Math.floor((event.clientX - rect.left) / scale),
y: Math.floor((event.clientY - rect.top) / scale)};
}
class PictureCanvas {
constructor(picture, mouseDown) {
this.dom = elt("canvas", {
onmousedown: event => this.mouseDown(event, mouseDown)
});
drawPicture(picture, this.dom, scale);
}
mouseDown(downEvent, onMouseDown) {
let rect = this.dom.getBoundingClientRect();
let pos = mousePosition(downEvent, rect);
let onMove = onMouseDown(pos);
if (!onMove) return;
let move = moveEvent => {
if (moveEvent.buttons == 0) {
this.dom.removeEventListener("mousemove", move);
} else {
let newPos = mousePosition(moveEvent, rect);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
}
};
this.dom.addEventListener("mousemove", move);
}
setState(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");
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
cx.fillStyle = picture.pixel(x, y);
cx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
class PixelEditor {
constructor(state, dispatch) {
this.state = state;
this.canvas = new PictureCanvas(state.picture, pos => {
let onMove = tools[this.state.tool](pos, this.state, dispatch);
return onMove && (pos => onMove(pos, this.state, dispatch));
});
this.controls = controls
.map(Control => new Control(state, dispatch));
this.dom = frag(this.canvas.dom, elt("br"),
...this.controls.reduce((a, c) => a.concat(" ", c.dom), []));
}
setState(state) {
this.state = state;
this.canvas.setState(state.picture);
for (let c of this.controls) c.setState(state);
}
}
let controls = [];
controls.push(class ToolSelect {
constructor(state, dispatch) {
this.select = elt("select", {
onchange: () => dispatch({tool: this.select.value})
}, ...Object.keys(tools).map(n => elt("option", {}, n)));
this.select.value = state.tool;
this.dom = frag("🖌 Tool: ", this.select);
}
setState(state) { this.select.value = state.tool; }
});
controls.push(class ColorSelect {
constructor(state, dispatch) {
this.input = elt("input", {
type: "color",
value: state.color,
onchange: () => dispatch({color: this.input.value})
});
this.dom = frag("🎨 Color: ", this.input);
}
setState(state) { this.input.value = state.color; }
});
controls.push(class SaveButton {
constructor(_, state) {
this.state = state;
this.dom = elt("button", {
onclick: () => this.save()
}, "💾 Save");
}
save() {
let canvas = elt("canvas");
drawPicture(this.state.picture, canvas, 1);
let link = elt("a", {
href: canvas.toDataURL(),
download: "pixelart.png"
});
document.body.appendChild(link);
link.click();
link.remove();
}
setState(state) { this.state = state; }
});
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) {
let h = n.toString(16);
return h.length == 1 ? "0" + h : h;
}
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);
}
controls.push(class LoadButton {
constructor(_, dispatch) {
this.dom = elt("button", {
onclick: () => this.load(dispatch)
}, "📁 Load");
}
load(dispatch) {
let input = elt("input", {type: "file"});
document.body.appendChild(input);
input.onchange = () => {
if (!input.files.length) return;
let reader = new FileReader();
reader.addEventListener("load", () => {
let image = elt("img", {
onload: () =>
dispatch({picture: pictureFromImage(image)}),
src: reader.result
});
});
reader.readAsDataURL(input.files[0]);
};
input.click();
input.remove();
}
setState(_) {}
});
let tools = {};
tools.draw = (pos, state, dispatch) => {
function drawDot(pos, state) {
let picture = state.picture.copy();
picture.setPixel(pos.x, pos.y, state.color);
dispatch({picture});
}
drawDot(pos, state);
return drawDot;
};
tools.rectangle = (start, state, dispatch) => {
function drawRect(pos) {
let xStart = Math.min(start.x, pos.x);
let xEnd = Math.max(start.x, pos.x);
let yStart = Math.min(start.y, pos.y);
let yEnd = Math.max(start.y, pos.y);
let picture = state.picture.copy();
for (let y = yStart; y <= yEnd; y++) {
for (let x = xStart; x <= xEnd; x++) {
picture.setPixel(x, y, state.color);
}
}
dispatch({picture});
}
drawRect(start);
return drawRect;
};
const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0},
{dx: 0, dy: -1}, {dx: 0, dy: 1}];
tools.fill = (pos, state, dispatch) => {
let targetColor = state.picture.pixel(pos.x, pos.y);
if (targetColor == state.color) return;
let picture = state.picture.copy();
let todo = [pos];
while (todo.length) {
let pos = todo.pop();
picture.setPixel(pos.x, pos.y, state.color);
for (let {dx, dy} of around) {
let x = pos.x + dx, y = pos.y + dy;
if (x >= 0 && x < picture.width &&
y >= 0 && y < picture.height &&
picture.pixel(x, y) == targetColor) {
todo.push({x, y});
}
}
}
dispatch({picture});
};
tools.pick = (pos, state, dispatch) => {
dispatch({color: state.picture.pixel(pos.x, pos.y)});
};
controls.push(class UndoButton {
constructor(state, dispatch) {
this.dom = elt("button", {
onclick: () => dispatch({history: "undo"}),
disabled: state.undo == null
}, "⮪ Undo");
}
setState(state) { this.dom.disabled = state.undo == null }
});
controls.push(class RedoButton {
constructor(state, dispatch) {
this.dom = elt("button", {
onclick: () => dispatch({history: "redo"}),
disabled: state.redo == null
}, "⮫ Redo");
}
setState(state) { this.dom.disabled = state.redo == null }
});
function historyDispatch(state, action) {
let time = Date.now();
if (action.history == "undo") {
return Object.assign({}, state, {
picture: state.undo.picture,
undo: state.undo.prev,
redo: {picture: state.picture, prev: state.redo, time}
});
} else if (action.history == "redo") {
return Object.assign({}, state, {
picture: state.redo.picture,
undo: {picture: state.picture, prev: state.undo, time},
redo: state.redo.prev,
});
} else {
let undo = state.undo;
if (action.picture && (!undo || undo.time < time - 2000)) {
undo = {picture: state.picture, time, prev: undo};
}
return Object.assign({}, state, action, {redo: null, undo});
}
}
let state = {
picture: Picture.empty(80, 30, "#eeeeee"),
tool: "draw",
color: "#000000"
};
let app = window.app = new PixelEditor(state, action => {
state = historyDispatch(state, action);
app.setState(state);
});
document.body.appendChild(app.dom);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment