/*
* PixelED.js - simple pixel-like editor for the browser
*
* This project is primarily just practice for The Odin Project's curriculum.
* (Found at https://theodinproject.com/) It claims this project is difficult,
* but the requirements are quite well-defined, so it doesn't strike me as
* difficult. Just tedious.
*
* This project was a good fit for TOP because it shows the student the
* difficulties in building intuitive and effective interfaces in the Web
* environment.
*
* Scope:
* [x] Erase button (sets all canvas pixels to white)
* [x] Black button, sets current color to black
* [x] White button, sets current color to white
* [x] 'Current color' indicator, clicking color box brings up dialog.
* [-] Reset button (sets canvas to 16x16, current color to black)
* '-> Refreshing the page does the same damn thing. YAGNI
* [x] Range slider for canvas size, with numeric indicator
* [x] Rainbow drawing mode that randomizes the color on every draw
* [x] Darken drawing mode that darkens the color by 10% on every draw
* '-> BONUS: Added a Lighten mode based on the reverse of Darken
* [x] 'Clear' button, to make the entire canvas white
*/
/* GOALS
*
* [*] discrete drawing states
* [*] click-and-drag to draw, not hover
* [*] right click to copy color under cursor (and switch to it)
*
* STRETCH GOALS
*
* [*] better UI layout
* [ ] possible export to GIF format (!!! :D)
*/
/* POST-COMPLETION
*
* This project has helped me understand what goes into making modern Web apps,
* and the value that some frameworks can bring when you're building something
* with somewhat complex UI language, like an editing tool. There is a lot of
* event and state syncing, for lack of a better explanation, which increases
* the complexity of the task you're carrying out. Web apps can be convenient
* to visit and use, but building them is a challenge. I think I would be more
* at home in a persistent ticking environment like a video game, or somewhat
* predictable backend code. The moving target of HTML5 and CSS3 and JS, while
* fun in some respects, adds to the ever-increasing complexity that developers
* face when building for the Web.
*
* I would prefer to build on a platform that is less prone to change. There's
* bound to be a middle ground between low-level tedium and a gargantuan
* virtual machine.
*/
/* CREDITS
*
* Wikipedia for the GIF information
* MDN for endless Web and Javascript info
* The Command Line Fanatic, who wrote two great articles on GIF:
* https://commandlinefanatic.com/cgi-bin/showarticle.cgi?article=art010
* https://commandlinefanatic.com/cgi-bin/showarticle.cgi?article=art011
*
* Implementing GIF would have been more of a hassle without these resources.
*/
/* disable right click's default behavior */
document.body.addEventListener("contextmenu",
(e) => {
e.preventDefault();
}
);
/* Target element for the application */
const root = document.getElementById('app_area');
const dstates = Object.freeze({
BLACK: 0,
WHITE: 1,
CUSTOM: 2,
RAINBOW: 3,
DARKEN: 4,
LIGHTEN: 5
});
current_color = "#000000";
custom_color = "#2564b8";
draw_state = dstates["BLACK"];
do_draw = false;
do_move = false;
function clean_element(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
function resetCanvas(size) {
clean_element(grid);
grid.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
rebuildCanvas(size);
}
function rebuildCanvas(size) {
size = lintCanvasSize(size);
for (i=0; i<(size*size); i++) {
box = grid.appendChild(document.createElement("div"));
box.addEventListener("mouseover", function(e) {
if (do_draw) {
drawColor(e.target, current_color);
}
});
box.addEventListener("mousedown", function(e) {
if (e.which == 3 || e.button == 2) {
if (e.target.style.backgroundColor == 'rgb(0, 0, 0)') {
setBlack();
} else if (e.target.style.backgroundColor == 'rgb(255, 255, 255)' || e.target.style.backgroundColor == '') {
setWhite();
} else {
custom_color = rgb_to_hex(e.target.style.backgroundColor);
document.querySelector("#custom_color").value = custom_color;
current_color = custom_color;
setCustom();
}
} else {
// do_draw = true;
drawColor(e.target, current_color);
}
}, { "passive": false, "capture": true } );
// box.addEventListener("mouseup", function(e) {
// do_draw = false;
// }, { "passive": false } );
}
}
function clearCanvas() {
if (window.confirm("Clear the entire canvas?")) {
pixels = document.querySelectorAll("#grid_container div");
pixels.forEach(function(i) {
i.removeAttribute("style");
});
}
}
function handler() {
initialSetup();
}
function initialSetup() {
workspace = document.createElement("div");
workspace.id = ["workspace"];
/* generate a 16x16 grid of divs */
grid = document.createElement("div");
grid.id = ["grid_container"];
grid.classList = ["show_grid"];
/* turn the pen off when you leave the canvas */
// grid.addEventListener("mouseleave",
// (e) => {
// do_draw = false;
// }, { "passive": false }
// );
resetCanvas(16);
/* we should have 256 divs appended to the grid. */
toolbox = document.createElement("div");
toolbox.id = ["toolbox"];
/* This is somewhat hackish, but takes up fewer lines of code than accomplishing
* the same thing in pure JS. It's all scaffolding anyway. */
toolbox.innerHTML = `
pixelED
`;
/* elements must be added to the page in order to be queried */
root.appendChild(toolbox);
root.appendChild(workspace);
workspace.appendChild(grid);
/* the area behind the grid */
workspace.addEventListener("mousedown", (e) => {
do_draw = true;
});
/* the area behind the grid */
workspace.addEventListener("mouseup", (e) => {
do_draw = false;
});
/* initial settings */
setBlack();
document.querySelector("#custom_color").value = custom_color;
/* click-and-drag for the toolbox */
document.querySelector("#toolbox header").addEventListener("mousedown", (e) => {
e.preventDefault();
x1 = e.clientX;
y1 = e.clientY;
xoff = e.clientX - e.target.parentNode.offsetLeft;
yoff = e.clientY - e.target.parentNode.offsetTop;
do_move = true;
document.addEventListener("mousemove", moveToolbox);
document.addEventListener("mouseup", (e) => {
e.preventDefault();
do_move = false;
}, { once: true });
});
/* add events, AFTER added to DOM above */
document.getElementById("rainbow").addEventListener("click", setRainbow);
document.getElementById("darken").addEventListener("click", setDarken);
document.getElementById("lighten").addEventListener("click", setLighten);
document.getElementById("clear_canvas").addEventListener("click", clearCanvas);
/* The canvas slider */
document.querySelector("#canvas_size").addEventListener(
"input",
(e) => {
let size = lintCanvasSize(e.target.value);
document.querySelector('#canvas_opts input[type="text"]').value = size;
e.target.value = size;
resetCanvas(size);
}
);
document.querySelector('#canvas_opts > input[type="text"]').addEventListener(
"change",
(e) => {
let size = lintCanvasSize(e.target.value);
document.querySelector("#canvas_size").value = size;
e.target.value = size;
resetCanvas(size);
}
);
document.querySelector('#canvas_opts > input[type="text"]').addEventListener(
"keydown",
(e) => {
let size = lintCanvasSize(e.target.value);
/* todo switch case */
if (e.key == "ArrowDown") {
if (size > 8) {
size -= 1;
e.target.value = size;
document.querySelector("#canvas_size").value = size;
resetCanvas(size);
}
// parseInt(e.target.value) -= 1;
} else if (e.key == "ArrowUp") {
if (size < 100) {
size += 1;
e.target.value = size;
document.querySelector("#canvas_size").value = size;
resetCanvas(size);
}
// parseInt(e.target.value) += 1;
}
}
);
/* The Black color option */
document.querySelectorAll('#black_pick > span').forEach(
(i) => {
i.addEventListener("click", setBlack);
}
);
document.querySelector('#black_pick').addEventListener("click", setBlack);
/* The White color option */
document.querySelectorAll('#white_pick > span').forEach(
(i) => {
i.addEventListener("click", setWhite);
}
);
document.querySelector('#white_pick').addEventListener("click", setWhite);
/* The Custom color option */
document.querySelector('#custom_color').addEventListener("input", setCustom);
document.querySelector('#custom_pick').addEventListener("click", setCustom);
/* The Toggle Grid option */
document.querySelector('#grid_toggle').addEventListener("click", toggleGrid);
}
function drawColor(e, c) {
switch (draw_state) {
case dstates["BLACK"]:
e.style.backgroundColor = "#000000";
break;
case dstates["WHITE"]:
e.style.backgroundColor = "#ffffff";
break;
case dstates["CUSTOM"]:
current_color = document.querySelector("#custom_color").value;
e.style.backgroundColor = current_color;
break;
case dstates["RAINBOW"]:
e.style.backgroundColor = gen_rainbow();
break;
case dstates["DARKEN"]:
e.style.backgroundColor = gen_darken(e.style.backgroundColor);
break;
case dstates["LIGHTEN"]:
e.style.backgroundColor = gen_lighten(e.style.backgroundColor);
break;
}
}
function setRainbow() {
draw_state = dstates["RAINBOW"];
clearSelected();
document.querySelector("#rainbow").classList.toggle("selected");
}
function setDarken() {
draw_state = dstates["DARKEN"];
clearSelected();
document.querySelector("#darken").classList.toggle("selected");
}
function setLighten() {
draw_state = dstates["LIGHTEN"];
clearSelected();
document.querySelector("#lighten").classList.toggle("selected");
}
function toggleGrid(e) {
document.querySelector("#grid_container").classList.toggle("show_grid");
document.querySelector("#grid_toggle").classList.toggle("active");
}
function lintCanvasSize(s) {
s = parseInt(s);
if (s < 8) {
s = 8;
} else if(s > 100) {
s = 100;
}
return s;
}
function clearSelected() {
document.querySelectorAll(".selected").forEach(
(i) => {
i.classList.toggle("selected");
}
);
}
function getRGBTriplet(s) {
console.log(s);
if (s.length == 0) {
return [255, 255, 255];
}
if (s[0] == "#" || s.length == 6) {
return hex_to_rgb(s);
}
triplet = s.match(new RegExp("rgb\\((.*)\\)"))[1].split(", ");
nums = new Array();
triplet.forEach(
(c) => {
nums.push(Number.parseInt(c));
}
);
return nums;
}
/* transform a hex color into rgb(x, y, z) format */
function hex_to_rgb(str) {
/* there are two main formats I want to support, though the third would be best, too:
*
* - "#abcdef": the typical hex color
* - "#ade" : shorthand for "#aaddee"
* - "#abcdef99": an alpha-aware hexcode, whose fourth set represents opacity
*/
if (str[0] == "#") {
str = str.substr(1);
// console.log(str);
}
switch (str.length) {
case 3:
/* shorthand hex */
break;
case 6:
/* standard hex */
let red = parseInt(str.substr(0,2),16);
let grn = parseInt(str.substr(2,2),16);
let blu = parseInt(str.substr(4,2),16);
return [red, grn, blu];
break;
case 8:
/* 32-bit hex color */
break;
}
}
function rgb_to_hex(str) {
if (str.length == 0) {
return "#ffffff";
}
let trp = getRGBTriplet(str);
let red = parseInt(trp[0],10).toString(16).padStart(2, '0');
let grn = parseInt(trp[1],10).toString(16).padStart(2, '0');
let blu = parseInt(trp[2],10).toString(16).padStart(2, '0');
return `#${red}${grn}${blu}`;
}
function setBlack() {
clearSelected();
document.querySelector("#black_pick").classList.toggle("selected");
draw_state = dstates["BLACK"];
}
function setWhite() {
clearSelected();
document.querySelector("#white_pick").classList.toggle("selected");
draw_state = dstates["WHITE"];
}
/* You have to click the itself to change the color. */
function setCustom() {
clearSelected();
document.querySelector("#custom_pick").classList.toggle("selected");
draw_state = dstates["CUSTOM"];
}
/* generate a random color and return its six digit hex code, with octothorpe (#) */
function gen_rainbow() {
let r = Math.floor(Math.random()*255);
let g = Math.floor(Math.random()*255);
let b = Math.floor(Math.random()*255);
let hr = r.toString(16).padStart(2, '0');
let hg = g.toString(16).padStart(2, '0');
let hb = b.toString(16).padStart(2, '0');
current_color = `#${hr}${hg}${hb}`;
document.querySelector("#custom_color").value = current_color;
return current_color;
}
/* accept a given 'rgb(r, g, b)' sequence, darken each color channel by 26, and return it */
function gen_darken(c) {
/* this happens on un-touched pixels, generally white by convention */
if (!c) {
return "rgb(224, 224, 224)";
}
let nc_array = [];
let c_array = getRGBTriplet(c);
c_array.forEach((i) => {
let num = parseInt(i);
num -= 26;
if (num < 0) {
num = 0;
}
nc_array.push(num);
});
let is = nc_array.join(", ");
return `rgb(${is})`;
}
/* accept a given 'rgb(r, g, b)' sequence, lighten each color channel by 26, and return it */
function gen_lighten(c) {
/* this happens on un-touched pixels, generally white by convention */
if (!c) {
return "rgb(255, 255, 255)";
}
let nc_array = [];
let c_array = getRGBTriplet(c);
c_array.forEach((i) => {
let num = parseInt(i);
num += 26;
if (num > 255) {
num = 255;
}
nc_array.push(num);
});
let is = nc_array.join(", ");
return `rgb(${is})`;
}
function moveToolbox(e) {
if (!do_move) {
return 0;
}
e = e || window.event;
e.preventDefault();
let tb = document.querySelector("#toolbox");
// console.log(`toolbar at ${tb.offsetLeft},${tb.offsetTop}`);
tb.style.left = `${e.clientX - xoff}px`;
tb.style.top = `${(e.clientY - yoff)}px`;
boundToolbox();
}
function myMid(lowb, num, highb) {
return Math.min(Math.max(lowb, num), highb);
}
function boundToolbox() {
let tb = document.querySelector("#toolbox");
let tbh = document.querySelector("#toolbox header");
let gap = tbh.style.gap;
tb.style.left = `${myMid(0,tb.offsetLeft,(window.innerWidth - tb.offsetWidth))}px`;
tb.style.top = `${myMid(0,tb.offsetTop,(window.innerHeight - (tbh.offsetHeight + 16)))}px`;
}
/* Implement a simple GIF class to facilitate easy downloading of user's work
*
* Limitations include:
* * no animation
* * no sub-images for complex palettes
* * no transparency
* * no palette correction should the colorspace run out
*
* In short, please only use this as a way to get pixelED art into another
* program.
*/
class GIFImage {
#header = "GIF89a";
#width;
#height;
#pixel_data;
#pixel_view;
raw_binary;
theGCT;
theIDS;
constructor(height, width, pdata) {
this.#width = width;
this.#height = height;
this.#pixel_data = new Uint8Array(3*(this.#width*this.#height));
this.#pixel_view = new DataView(this.#pixel_data.buffer, 0);
this.fill_buffer(pdata);
this.build_gct();
}
// fill the data buffer with background color information from this element's children.
fill_buffer(grid_area) {
if (grid_area.children.length != (this.#pixel_data.length / 3) ) {
console.log("pixelED Error: Size mismatch between canvas and GIF buffer size!");
return;
} else {
// every pixel has three values, thus we can know offsets
for (i=0; i {
let hex = rgb_to_hex(p.style.backgroundColor).substring(1);
if (! this.theGCT.includes(hex)) {
this.theGCT.push(hex);
}
let ci = this.theGCT.indexOf(hex);
this.theIDS.push(ci);
}
);
}
gct_to_binary() {
let buf = new Uint8Array(128*3);
for (i=0; (i/3) <= this.theGCT.length; i+=3) {
let rgb = getRGBTriplet(this.theGCT[i/3]);
buf[i] = rgb[0];
buf[i+1] = rgb[1];
buf[i+2] = rgb[2];
}
return new DataView(buf.buffer, 0);
}
ids_to_binary() {
let buf = new Uint8Array(this.theIDS.length);
for (i=0; i < buf.byteLength; i++) {
buf[i] = this.theIDS[i];
}
return new DataView(buf.buffer, 0);
}
// don't hurt me i'm still a babby
writeData() {
// we need to figure out the length of our information before we allocate a buffer.
let size = 0;
size += 6; // header: 'GIF89a'
size += 2; // logical screen width
size += 2; // logical screen height
size += 1; // GCT (Global Color Table) information
// -- u are here
size += 1; // background color index
size += 1; // pixel aspect ratio
size += this.gct_to_binary().byteLength;
// we'll try omitting the GCE
size += 1; // image descriptor ','
size += 4; // upper-left coord of image in logical screen
size += 4; // image width and height
size += 1; // local color table bit
size += 1; // minimum LZW code size - 1 (7 for 8-bit indices)
size += 1; // CLEAR code (80)
size += this.ids_to_binary().byteLength;
size += 1; // STOP code (81)
size += 1; // last ';' in the file
console.log("we need "+size+" bytes");
// do the business
this.raw_binary = new Uint8Array(size);
let offset = 0;
// write header
for (i=0; i> 8;
offset++;
this.raw_binary[offset] = (this.#height & 255);
offset++;
this.raw_binary[offset] = (this.#height) >> 8;
offset++;
// write BG index and aspect ratio
this.raw_binary[offset++] = 0;
this.raw_binary[offset++] = 0;
let packed_field = 0b10110110;
console.log(offset);
}
}
/* vim: set foldmethod=syntax: */