/* * 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: */