const commandBarElement = document.getElementById('menu-bar'); const toolBarElement = document.getElementById('tool-bar'); const layerBarElement = document.getElementById('layer-bar'); const studioElement = document.getElementById('studio'); const infoBarElement = document.getElementById('info-bar'); const easelElement = document.getElementById('easel'); const brushPreviewElement = document.getElementById('brush-preview'); const dZoom = 0.001; const dBrushSize = 0.5; const initialWidth = 800; const initialHeight = 600; const maxBrushSize = 500; let brushColor = 'rgb(0, 0, 0)'; let brushShape = 'square' let brushSize = 1; let zoom = 1; let currentTool = 'brush' let prevTool = 'brush' let startX = 0; let startY = 0; let endX = 0; let endY = 0; let dX = 0; let dY = 0; let canvasStartX = 0; let canvasStartY = 0; let canvasEndX = 0; let canvasEndY = 0; let canvasDX = 0; let canvasDY = 0; let isKeyDown = false; let isMouseDown = false; // HELPERS {{{ function hexToRgbArray(hex) { if (hex.startsWith('#')) { hex = hex.slice(1); } if (hex.length === 3) { hex = hex.split('').map(char => char + char).join(''); } const bigint = parseInt(hex, 16); return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]; } function colorsMatch(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } function makeButtonElement({icon, name, clickFunction, key}) { if (!icon) throw new Error('No icon provided'); if (!name) throw new Error('No name provided'); if (!clickFunction) throw new Error('No click function provided'); if (!key) throw new Error('No key provided'); const buttonElement = document.createElement('div'); buttonElement.className = 'button'; buttonElement.innerHTML = icon; buttonElement.title = name; buttonElement.addEventListener('click', clickFunction); if (key) { const keyHint = document.createElement('span'); keyHint.className = 'key-hint'; keyHint.innerHTML = key; buttonElement.appendChild(keyHint); } return buttonElement; } // }}} // LAYERS {{{ // factory {{{ function makeCanvas({height=600, width=800}) { // {{{ const canvas = document.createElement('canvas'); easelElement.appendChild(canvas); canvas.style.imageRendering = 'pixelated'; canvas.ctx = canvas.getContext('2d'); canvas.tempCanvas = document.createElement('canvas'); canvas.tempCtx = canvas.tempCanvas.getContext('2d'); canvas.disableImageSmoothing = function(ctx) { ctx.imageSmoothingEnabled = false; if (ctx.imageSmoothingEnabled !== false) { ctx.mozImageSmoothingEnabled = false; ctx.webkitImageSmoothingEnabled = false; ctx.msImageSmoothingEnabled = false; } }; canvas.saveTempCanvas = function() { canvas.ctx.save(); canvas.tempCanvas.width = canvas.width; canvas.tempCanvas.height = canvas.height; canvas.disableTempImageSmoothing(canvas.tempCtx); canvas.tempCtx.drawImage(canvas, 0, 0); } canvas.restoreTempCanvas = function() { canvas.ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.ctx.drawImage(canvas.tempCanvas, 0, 0); canvas.ctx.restore(); } canvas.setHeight = function(height) { canvas.height = height; canvas.disableImageSmoothing(canvas.ctx); }; canvas.setWidth = function(width) { canvas.width = width; canvas.disableImageSmoothing(canvas.ctx); }; canvas.getPositionOnCanvas = function(e) { const rect = canvas.getBoundingClientRect(); return { x: Math.round((e.clientX - rect.left) / zoom), y: Math.round((e.clientY - rect.top) / zoom), }; } canvas.drawPixel = function(x, y, color) { console.log({x, y, color}); canvas.ctx.fillStyle = color; canvas.ctx.fillRect(x, y, 1, 1); } canvas.drawLineWithPixels = function(x1, y1, x2, y2, color) { const dx = Math.abs(x2 - x1); const dy = Math.abs(y2 - y1); const sx = x1 < x2 ? 1 : -1; const sy = y1 < y2 ? 1 : -1; let err = dx - dy; while (true) { canvas.drawPixel(x1, y1, color); // Draw each pixel along the line if (x1 === x2 && y1 === y2) break; const e2 = err * 2; if (e2 > -dy) { err -= dy; x1 += sx; } if (e2 < dx) { err += dx; y1 += sy; } } } canvas.drawShape = function(x, y, shape, size, color) { if (size == 1) { canvas.drawPixel(x, y, color); return; } canvas.ctx.fillStyle = color; if (shape === 'square') { canvas.ctx.fillRect(x - Math.floor(size / 2), y - Math.floor(size / 2), size, size); } } canvas.getColorAtPixel = function(data, x, y) { const index = (y * canvas.width + x) * 4; return [data[index], data[index + 1], data[index + 2], data[index + 3]]; } canvas.setColorAtPixel = function(data, x, y, color) { const index = (y * canvas.width + x) * 4; data[index] = color[0]; data[index + 1] = color[1]; data[index + 2] = color[2]; data[index + 3] = 255; } canvas.fill = function(color) { canvas.ctx.fillStyle = color; canvas.ctx.fillRect(0, 0, canvas.width, canvas.height); } canvas.floodFill = function(x, y, color) { const imageData = canvas.ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; const targetColor = canvas.getColorAtPixel(data, x, y); const fillColorArray = hexToRgbArray(color); if (colorsMatch(targetColor, fillColorArray)) { return; } const stack = [{x, y}]; while (stack.length > 0) { const {x, y} = stack.pop(); const currentColor = canvas.getColorAtPixel(data, x, y); if (colorsMatch(currentColor, targetColor)) { canvas.setColorAtPixel(data, x, y, fillColorArray); if (x > 0) stack.push({x: x - 1, y}); if (x < canvas.width - 1) stack.push({x: x + 1, y}); if (y > 0) stack.push({x, y: y - 1}); if (y < canvas.height - 1) stack.push({x, y: y + 1}); } } canvas.ctx.putImageData(imageData, 0, 0); } canvas.toDataUrl = function() { const dataURL = canvas.toDataURL(); const dimensions = `${canvas.width}x${canvas.height}`; return {dataURL, dimensions}; } canvas.fromDataUrl = function(dataURL, dimensions) { const img = new Image(); img.src = dataURL; img.onload = function() { canvas.width = dimensions.split('x')[0]; canvas.height = dimensions.split('x')[1]; canvas.style.width = canvas.width * zoom + 'px'; canvas.style.height = canvas.height * zoom + 'px'; canvas.ctx.drawImage(img, 0, 0); } } canvas.deleteCanvas = function() { canvas.remove(); } canvas.setWidth(width); canvas.setHeight(height); return canvas; } // }}} function makeLayer({height=600, width=800}) { // {{{ const layer = {} layer.canvas = makeCanvas({height, width}); layer.active = false; layer.opacity = 1; layer.controllerElement = document.createElement('div'); layer.controllerElement.className = 'layer-controller'; layer.controllerElement.addEventListener('click', () => { layers.setActive(layer); }); layer.activate = function() { layer.active = true; layer.controllerElement.classList.add('active'); } layer.deactivate = function() { layer.active = false; layer.controllerElement.classList.remove('active'); } return layer; } // }}} function makeLayers({height=600, width=800}) { // {{{ const layers = []; layers.height = height; layers.width = width; layers.setHeight = function(height) { layers.height = height; easelElement.style.height = height + 2 + 'px'; } layers.setHeight(height); layers.setWidth = function(width) { layers.width = width; easelElement.style.width = width + 2 + 'px'; } layers.setWidth(width); layers.resetPosition = function() { const studioRect = studioElement.getBoundingClientRect(); easelElement.style.left = `${studioRect.left}px`; easelElement.style.top = `${studioRect.top}px`; } layers.updateControllers = function() { layerBarElement.innerHTML = ''; layers.forEach(layer => { layerBarElement.appendChild(layer.controllerElement); }); } layers.add = function() { const layer = makeLayer({ height: layers.height, width: layers.width, }); layers.push(layer); layer.activate(); layers.updateControllers(); } layers.delete = function(layer) { layer.canvas.deleteCanvas(); layers.splice(layers.indexOf(layer), 1); layers.updateControllers(); } layers.deleteAll = function() { layers.forEach(layer => layer.deleteCanvas()); // TODO } layers.move = function(layer, index) { layers.splice(layers.indexOf(layer), 1); layers.splice(index, 0, layer); } layers.setActive = function(layer) { layers.forEach(layer => layer.deactivate()); layer.activate(); } layers.getActive = function() { return layers.find(layer => layer.active); } return layers; } // }}} // }}} const layers = makeLayers({height: initialHeight, width: initialWidth}); layers.add(); layers.add(); layers[0].canvas.fill('rgb(255, 255, 255)'); layers.setActive(layers[1]); // }}} // COLOR PREVIEW {{{ function makeColorPreview() { const colorPreview = {} colorPreview.element = document.createElement('div'); colorPreview.element.id = 'color-preview'; colorPreview.element.className = 'puck'; colorPreview.element.style.backgroundColor = brushColor; commandBarElement.appendChild(colorPreview.element); colorPreview.update = function() { colorPreview.element.style.backgroundColor = brushColor; } } const colorPreview = makeColorPreview(); // }}} // COMMANDS {{{ // factory {{{ function makeCommand({name, key, icon, clickFunction}) { if (!name) throw new Error('No name provided'); if (!icon) throw new Error('No icon provided'); if (!clickFunction) throw new Error('No click function provided'); if (!key) throw new Error('No key provided'); const command = {}; command.name = name; command.key = key; command.buttonElement = makeButtonElement({icon, name, clickFunction, key}); commandBarElement.appendChild(command.buttonElement); return command } function makeCommands() { const commands = []; commands.add = function({name, key, icon, clickFunction}) { const command = makeCommand({name, key, icon, clickFunction}); commands.push(command); } return commands; } // }}} const commands = makeCommands(); commands.add({ // flip-horizontally {{{ name: 'flip-horizontally', key: 'f', icon: '', clickFunction: function flipCanvasHorizontally() { const canvas = layers.getActive().canvas; const ctx = canvas.ctx; ctx.save(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.scale(-1, 1); ctx.translate(-canvas.width, 0); canvas.restoreTempCanvas(); ctx.restore(); } }); // }}} commands.add({ // flip-vertically {{{ name: 'flip-vertically', key: 'v', icon: '', clickFunction: function flipCanvasVertically() { const canvas = layers.getActive().canvas; const ctx = canvas.ctx; ctx.save(); canvas.saveTempCanvas(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.scale(1, -1); ctx.translate(0, -canvas.height); canvas.restoreTempCanvas(); ctx.restore(); } }); // }}} commands.add({ // export {{{ name: 'export', key: 'e', icon: '', clickFunction: function exportCanvas() { const canvas = layers.getActive().canvas; const link = document.createElement('a'); link.download = 'canvas.png'; link.href = canvas.toDataURL(); link.click(); } }); // }}} commands.add({ // import {{{ name: 'import', key: 'i', icon: '', clickFunction: function importCanvas() { const canvas = layers.getActive().canvas; const ctx = canvas.ctx; const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = (e) => { const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); } img.src = e.target.result; } reader.readAsDataURL(file); } input.click(); } }); // }}} commands.add({ // clear {{{ name: 'clear', key: 'c', icon: '', clickFunction: function clearCanvas() { const canvas = layers.getActive().canvas; const ctx = canvas.ctx; canvas.saveTempCanvas(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'white'; ctx.fillRect(0, 0, canvas.width, canvas.height); } }); // }}} commands.add({ // reset {{{ name: 'reset', key: 't', icon: '', clickFunction: function resetZoom() { layers.resetPosition(); // zoom = 1; // canvas.style.width = canvas.width * zoom + 'px'; // canvas.style.height = canvas.height * zoom + 'px'; // TODO } }); // }}} // }}} // TOOLS {{{ // factory {{{ function makeTool({name, key, icon, mouseDown, mouseMove, mouseUp}) { if (!name) throw new Error('No name provided'); if (!key) throw new Error('No key provided'); if (!icon) throw new Error('No icon provided'); const tool = {}; tool.name = name; tool.key = key; tool.icon = icon; tool.mouseDown = mouseDown; tool.mouseMove = mouseMove; tool.mouseUp = mouseUp; tool.active = false; tool.buttonElement = makeButtonElement({ icon: tool.icon, name: tool.name, key: tool.key, clickFunction: function() { tools.activate(tool); tool.buttonElement.classList.add('active'); } }); toolBarElement.appendChild(tool.buttonElement); tool.activate = function() { currentTool = tool.name; tool.active = true; } tool.deactivate = function() { tool.active = false; tool.buttonElement.classList.remove('active'); } return tool; } function makeTools() { const tools = []; tools.add = function({name, key, icon, mouseDown, mouseMove, mouseUp}) { const tool = makeTool({name, key, icon, mouseDown, mouseMove, mouseUp}); tools.push(tool); } tools.activate = function(tool) { tools.forEach(tool => tool.deactivate()); tool.activate(); } return tools; } // }}} const tools = makeTools(); tools.add({ // brush {{{ name: 'brush', key: 'b', icon: '', mouseDown: function(e) { const canvas = layers.getActive().canvas; if (brushSize == 1) { canvas.drawPixel(canvasStartX, canvasStartY, brushColor); } else { canvas.drawShape(canvasStartX, canvasStartY, brushShape, brushSize, brushColor); } }, mouseMove: function(e) { const canvas = layers.getActive().canvas; if (brushSize == 1) { canvas.drawLineWithPixels(canvasStartX, canvasStartY, canvasEndX, canvasEndY, brushColor); return; } else { canvas.drawShape(canvasEndX, canvasEndY, brushShape, brushSize, brushColor); } canvasStartX = canvasEndX; canvasStartY = canvasEndY; }, }); // }}} tools.add({ // content-move {{{ name: 'content-move', key: 'h', icon: '', mouseMove: function(e) { const canvas = layers.getActive().canvas; canvas.ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.ctx.fillStyle = 'white'; canvas.ctx.fillRect(0, 0, canvas.width, canvas.height); canvas.restoreTempCanvas(); }, }); // }}} tools.add({ // move {{{ name: 'move', key: 'm', icon: '', mouseDown: function(e) { startX = e.clientX - easelElement.offsetLeft; startY = e.clientY - easelElement.offsetTop; }, mouseMove: function(e) { easelElement.style.left = dX + 'px'; easelElement.style.top = dY + 'px'; }, }); // }}} tools.add({ // zoom {{{ name: 'zoom', key: 'z', icon: '', mouseMove: function(e) { // TODO all canvases // const canvas = layers.getActive().canvas; zoom += dX * dZoom; if (zoom < 0.1) zoom = 0.1; // canvas.style.height = canvasHeight * zoom + 'px'; // canvas.style.width = canvasWidth * zoom + 'px'; startX = endX; } }); // }}} tools.add({ // bucket-fill {{{ name: 'bucket-fill', key: 'k', icon: '', mouseDown: function(e) { // canvas = layers.getActive().canvas; // canvas.floodFill(canvasStartX, canvasStartY, brushColor); } }); // }}} tools.add({ // color-picker {{{ name: 'color-picker', key: 'a', icon: '', mouseDown: function(e) { const canvas = layers.getActive().canvas; const imageData = canvas.ctx.getImageData(canvasStartX, canvasStartY, 1, 1).data; const pickedColor = `rgb(${imageData[0]}, ${imageData[1]}, ${imageData[2]})`; brushColor = pickedColor; colorPreview.update(); } }); // }}} tools.add({ // brush-size {{{ name: 'brush-size', key: 'd', icon: '', mouseMove: function(e) { brushSize += dX * dBrushSize; if (brushSize < 1) brushSize = 1; if (brushSize > maxBrushSize) brushSize = maxBrushSize; startX = endX; } }); // }}} tools.add({ // resize {{{ name: 'resize', key: 'r', icon: '', mouseMove: function(e) { // const canvas = layers.getActive().canvas; // let newWidth = canvasWidth + dX / zoom; // let newHeight = canvasHeight + dY / zoom; // if (newWidth > 0 && newHeight > 0) { // canvas.setWidth(newWidth); // canvas.setHeight(newHeight); // canvas.style.width = newWidth * zoom + 'px'; // canvas.style.height = newHeight * zoom + 'px'; // canvas.ctx.clearRect(0, 0, canvas.width, canvas.height); // canvas.ctx.fillStyle = backgroundColor; // canvas.ctx.fillRect(0, 0, canvas.width, canvas.height); // canvas.ctx.drawImage(tempCanvas, 0, 0); // } } }); // }}} tools.add({ // color-mix {{{ name: 'color-mix', key: 'x', icon: '', mouseMove: function(e) { // const canvas = layers.getActive().canvas; const imageData = ctx.getImageData(canvasEndX, canvasEndY, 1, 1).data; const canvasColor = `rgb(${imageData[0]}, ${imageData[1]}, ${imageData[2]})`; const distance = Math.sqrt(Math.pow(e.clientX - startX, 2) + Math.pow(e.clientY - startY, 2)); const t = Math.min(1, distance / 300); const mixedColor = mixbox.lerp(brushColor, canvasColor, t); brushColor = mixedColor; startX = e.clientX; startY = e.clientY; } }); // }}} // }}} // PUCKS {{{ // factory {{{ function createPuck({puckColor, key, editable=true}) { if (!puckColor) throw new Error('No puck color provided'); const puck = {} puck.element = document.createElement('div'); puck.element.style.backgroundColor = puckColor; puck.element.className = 'puck'; if (editable) { const deleteHandle = document.createElement('div'); deleteHandle.className = 'delete-handle'; deleteHandle.innerHTML = ''; puck.element.appendChild(deleteHandle); deleteHandle.addEventListener('click', () => { puck.element.remove(); }); } if (key) { const keyHint = document.createElement('div'); keyHint.className = 'key-hint'; keyHint.innerHTML = key; puck.element.appendChild(keyHint); } function mixx(startTime) { var interval = setInterval(() => { const elapsedTime = Date.now() - startTime; const t = Math.min(1, elapsedTime / 10000); const mixedColor = mixbox.lerp(brushColor, puck.style.backgroundColor, t); brushColor = mixedColor; colorPreview.update(); infos.update(); }, 50); return interval; } puck.element.addEventListener('mousedown', () => { const startTime = Date.now(); var interval = mixx(startTime); function onMouseUp() { clearInterval(interval); document.removeEventListener('mouseup', onMouseUp); } document.addEventListener('mouseup', onMouseUp); }); puck.keydown = function(e) { if (e.key == key) { const startTime = Date.now(); var interval = mixx(startTime); function onKeyUp() { clearInterval(interval); document.removeEventListener('keyup', onKeyUp); } document.addEventListener('keyup', onKeyUp); } } commandBarElement.appendChild(puck.element); } function makePucks() { const pucks = []; pucks.add = function({puckColor, key, editable}) { const puck = createPuck({puckColor, key, editable}); pucks.push(puck); } return pucks; } // }}} const pucks = makePucks(); pucks.add({ puckColor: 'rgb(0, 0, 0)', key: '1', editable: false, }); pucks.add({ puckColor: 'rgb(255, 255, 255)', key: '2', editable: false, }); pucks.add({ puckColor: 'rgb(255, 0, 0)', key: '3', editable: false, }); pucks.add({ puckColor: 'rgb(0, 255, 0)', key: '4', editable: false, }); pucks.add({ puckColor: 'rgb(0, 0, 255)', key: '5', editable: false, }); // }}} // INFO {{{ function makeInfo({name, updateFunction}) { if (!name) throw new Error('No name provided'); if (!updateFunction) throw new Error('No update function provided'); const info = {}; info.name = name; info.updateFunction = updateFunction; info.element = document.createElement('span'); info.element.className = 'info'; const key = document.createElement('span'); key.className = 'key'; key.innerHTML = info.name + ':'; const value = document.createElement('span'); value.className = 'value'; value.innerHTML = '0'; info.element.appendChild(key); info.element.appendChild(value); infoBarElement.appendChild(info.element); info.update = function() { let v = updateFunction(); if (v === undefined) v = '?'; value.innerHTML = v; } return info; } function makeInfos() { const infos = [] infos.add = function({name, updateFunction}) { const info = makeInfo({name, updateFunction}); infos.push(info); } infos.update = function() { infos.forEach(function(info){ info.update(); }); } return infos; } const infos = makeInfos(); infos.add({ name: 'zoom', updateFunction: function() { var percent = zoom * 100; return percent.toFixed(0) + '%'; } }); infos.add({ name: 'brush', updateFunction: function() { return brushSize; } }); infos.add({ name: 'x', updateFunction: function() { return canvasEndX; } }); infos.add({ name: 'y', updateFunction: function() { return canvasEndY; } }); infos.add({ name: 'color', updateFunction: function() { return brushColor; } }); infos.add({ name: 'width', updateFunction: function() { return "width"; } }); infos.add({ name: 'height', updateFunction: function() { return "height"; } }); // }}} // MOUSE EVENT LISTENERS {{{ studioElement.addEventListener('mousedown', (e) => { const canvas = layers.getActive().canvas; isMouseDown = true; startX = e.clientX; startY = e.clientY; canvasStartX = canvas.getPositionOnCanvas(e).x; canvasStartY = canvas.getPositionOnCanvas(e).y; for (var i = 0; i < tools.length; i++) { var tool = tools[i]; if (tool.name === currentTool) { if (tool.mouseDown) { tool.mouseDown(e); break; } } } infos.update(); }); studioElement.addEventListener('mousemove', (e) => { const canvas = layers.getActive().canvas; endX = e.clientX; endY = e.clientY; dX = endX - startX; dY = endY - startY; canvasEndX = canvas.getPositionOnCanvas(e).x; canvasEndY = canvas.getPositionOnCanvas(e).y; canvasDX = canvasEndX - canvasStartX; canvasDY = canvasEndY - canvasStartY; if (currentTool == 'brush-size') { brushPreviewElement.style.display = 'block'; brushPreviewElement.style.width = brushSize + 'px'; brushPreviewElement.style.height = brushSize + 'px'; brushPreviewElement.style.left = e.clientX - brushSize / 2 + 'px'; brushPreviewElement.style.top = e.clientY - brushSize / 2 + 'px'; } if (isMouseDown) { for (var i = 0; i < tools.length; i++) { var tool = tools[i]; if (tool.name === currentTool) { if (tool.mouseMove) { tool.mouseMove(e); break; } } } } infos.update(); }); studioElement.addEventListener('mouseup', () => { isMouseDown = false; infos.update(); }); studioElement.addEventListener('mouseleave', () => { isMouseDown = false; brushPreviewElement.style.display = 'none'; infos.update(); }); // }}} // KEYBINDINGS {{{ document.addEventListener('keydown', (e) => { if (isKeyDown) return; tools.forEach(tool => { if (tool.key.toLowerCase() === e.key.toLowerCase()) { prevTool = currentTool; currentTool = tool.name; } }); commands.forEach(command => { if (command.key.toLowerCase() === e.key.toLowerCase()) { command.clickFunction(); } }); pucks.filter(puck => puck.key).forEach(puck => { if (puck.key.toLowerCase() === e.key.toLowerCase()) { puck.keydown(e); } }); isKeyDown = true; }); document.addEventListener('keyup', (e) => { tools.forEach(tool => { if (tool.key.toLowerCase() === e.key) { currentTool = prevTool; } }); isKeyDown = false; }); // }}} layers.resetPosition();