From c63003e1cff4d809d4077b867c591be3787d9bb5 Mon Sep 17 00:00:00 2001 From: Victor Seiji Hariki Date: Thu, 1 Dec 2022 18:10:30 -0300 Subject: [PATCH] a quick try at a tool for painting From yesterday night, just finished some final touches; should be enough for some cool things. Signed-off-by: Victor Seiji Hariki --- index.html | 5 +- js/index.js | 6 +- js/initalize/debug.populate.js | 9 +- js/initalize/layers.populate.js | 26 +- .../shortcuts.populate.js} | 3 + js/initalize/toolbar.populate.js | 1 + js/lib/commands.js | 25 +- js/lib/input.js | 2 +- js/lib/layers.js | 19 +- js/lib/util.js | 79 +++--- js/ui/tool/colorbrush.js | 247 ++++++++++++++++++ res/icons/brush.svg | 5 + 12 files changed, 370 insertions(+), 57 deletions(-) rename js/{shortcuts.js => initalize/shortcuts.populate.js} (88%) create mode 100644 js/ui/tool/colorbrush.js create mode 100644 res/icons/brush.svg diff --git a/index.html b/index.html index 3e07789..04b80eb 100644 --- a/index.html +++ b/index.html @@ -210,16 +210,19 @@ - + + diff --git a/js/index.js b/js/index.js index bf890f5..539afbb 100644 --- a/js/index.js +++ b/js/index.js @@ -48,6 +48,7 @@ var stableDiffusionData = { }; // stuff things use +let debug = false; var returnedImages; var imageIndex = 0; var tmpImgXYWH = {}; @@ -126,6 +127,7 @@ function testHostConfiguration() { * Check host configuration */ const hostEl = document.getElementById("host"); + hostEl.value = localStorage.getItem("host"); const requestHost = (prompt, def = "http://127.0.0.1:7860") => { let value = window.prompt(prompt, def); @@ -549,7 +551,9 @@ function changeSnapMode() { } function changeMaskBlur() { - stableDiffusionData.mask_blur = document.getElementById("maskBlur").value; + stableDiffusionData.mask_blur = parseInt( + document.getElementById("maskBlur").value + ); localStorage.setItem("mask_blur", stableDiffusionData.mask_blur); } diff --git a/js/initalize/debug.populate.js b/js/initalize/debug.populate.js index 8e40860..8033776 100644 --- a/js/initalize/debug.populate.js +++ b/js/initalize/debug.populate.js @@ -24,6 +24,11 @@ mouse.listen.world.onmousemove.on((evn) => { */ const toggledebug = () => { const hidden = debugCanvas.style.display === "none"; - if (hidden) debugLayer.unhide(); - else debugLayer.hide(); + if (hidden) { + debugLayer.unhide(); + debug = true; + } else { + debugLayer.hide(); + debug = false; + } }; diff --git a/js/initalize/layers.populate.js b/js/initalize/layers.populate.js index ca06b86..8c50a03 100644 --- a/js/initalize/layers.populate.js +++ b/js/initalize/layers.populate.js @@ -54,7 +54,7 @@ mouse.registerContext( const target = evn.target; // Get element bounding rect - const bb = target.getBoundingClientRect(); + const bb = imageCollection.element.getBoundingClientRect(); // Get element width/height (css, cause I don't trust client sizes in chrome anymore) const w = imageCollection.size.w; @@ -148,11 +148,13 @@ mouse.listen.window.onwheel.on((evn) => { viewport.transform(imageCollection.element); - debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); - debugCtx.fillStyle = "#F0F"; - debugCtx.beginPath(); - debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); - debugCtx.fill(); + if (debug) { + debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); + debugCtx.fillStyle = "#F0F"; + debugCtx.beginPath(); + debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); + debugCtx.fill(); + } } }); @@ -173,11 +175,13 @@ mouse.listen.window.btn.middle.onpaint.on((evn) => { } viewport.transform(imageCollection.element); - debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); - debugCtx.fillStyle = "#F0F"; - debugCtx.beginPath(); - debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); - debugCtx.fill(); + if (debug) { + debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); + debugCtx.fillStyle = "#F0F"; + debugCtx.beginPath(); + debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); + debugCtx.fill(); + } }); mouse.listen.window.btn.middle.onpaintend.on((evn) => { diff --git a/js/shortcuts.js b/js/initalize/shortcuts.populate.js similarity index 88% rename from js/shortcuts.js rename to js/initalize/shortcuts.populate.js index 8846ded..0c6cf0e 100644 --- a/js/shortcuts.js +++ b/js/initalize/shortcuts.populate.js @@ -14,6 +14,9 @@ keyboard.onShortcut({key: "KeyD"}, () => { keyboard.onShortcut({key: "KeyM"}, () => { tools.maskbrush.enable(); }); +keyboard.onShortcut({key: "KeyC"}, () => { + tools.colorbrush.enable(); +}); keyboard.onShortcut({key: "KeyI"}, () => { tools.img2img.enable(); }); diff --git a/js/initalize/toolbar.populate.js b/js/initalize/toolbar.populate.js index c7052df..37c622a 100644 --- a/js/initalize/toolbar.populate.js +++ b/js/initalize/toolbar.populate.js @@ -15,6 +15,7 @@ toolbar.addSeparator(); * Mask Brush tool */ tools.maskbrush = maskBrushTool(); +tools.colorbrush = colorBrushTool(); /** * Image Editing tools diff --git a/js/lib/commands.js b/js/lib/commands.js index 20211b1..91bcaae 100644 --- a/js/lib/commands.js +++ b/js/lib/commands.js @@ -278,7 +278,30 @@ commands.createCommand( } // Apply command - state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h); + const style = state.context.fillStyle; + state.context.fillStyle = "black"; + + const op = state.context.globalCompositeOperation; + state.context.globalCompositeOperation = "destination-out"; + + if (options.mask) + state.context.drawImage( + options.mask, + state.box.x, + state.box.y, + state.box.w, + state.box.h + ); + else + state.context.fillRect( + state.box.x, + state.box.y, + state.box.w, + state.box.h + ); + + state.context.fillStyle = style; + state.context.globalCompositeOperation = op; }, (title, state) => { // Clear destination area diff --git a/js/lib/input.js b/js/lib/input.js index 5c9608b..4e625ee 100644 --- a/js/lib/input.js +++ b/js/lib/input.js @@ -288,7 +288,7 @@ window.addEventListener( window.addEventListener( "mousemove", (evn) => { - mouse._contexts.forEach((context) => { + mouse._contexts.forEach(async (context) => { const target = context.target; const name = context.name; diff --git a/js/lib/layers.js b/js/lib/layers.js index 6e38c9b..cede377 100644 --- a/js/lib/layers.js +++ b/js/lib/layers.js @@ -172,13 +172,30 @@ const layers = { * Moves this layer to another location * * @param {number} x X coordinate of the top left of the canvas - * @param {number} y X coordinate of the top left of the canvas + * @param {number} y Y coordinate of the top left of the canvas */ moveTo(x, y) { canvas.style.left = `${x}px`; canvas.style.top = `${y}px`; }, + /** + * Resizes layer in place + * + * @param {number} w New width + * @param {number} h New height + */ + resize(w, h) { + canvas.width = Math.round( + options.resolution.w * (w / options.bb.w) + ); + canvas.height = Math.round( + options.resolution.h * (h / options.bb.h) + ); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + }, + // Hides this layer (don't draw) hide() { this.canvas.style.display = "none"; diff --git a/js/lib/util.js b/js/lib/util.js index 03f6a6d..6dd4c9a 100644 --- a/js/lib/util.js +++ b/js/lib/util.js @@ -197,57 +197,58 @@ function getBoundingBox(cx, cy, w, h, gridSnap = null) { }; } +class NoContentError extends Error {} + /** * Crops a given canvas to content, returning a new canvas object with the content in it. * * @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from - * @returns {HTMLCanvasElement} A new canvas with the cropped part of the image + * @param {object} options Extra options + * @param {number} [options.border=0] Extra border around the content + * @returns {{canvas: HTMLCanvasElement, bb: BoundingBox}} A new canvas with the cropped part of the image */ -function cropCanvas(sourceCanvas) { - var w = sourceCanvas.width; - var h = sourceCanvas.height; - var pix = {x: [], y: []}; - var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h); - var x, y, index; +function cropCanvas(sourceCanvas, options = {}) { + defaultOpt(options, {border: 0}); - for (y = 0; y < h; y++) { - for (x = 0; x < w; x++) { + const w = sourceCanvas.width; + const h = sourceCanvas.height; + var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h); + /** @type {BoundingBox} */ + const bb = {x: 0, y: 0, w: 0, h: 0}; + + let minx = w; + let maxx = -1; + let miny = h; + let maxy = -1; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { // lol i need to learn what this part does - index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords + const index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords //this part i get, this is checking that 4th RGBA byte for opacity if (imageData.data[index + 3] > 0) { - pix.x.push(x); - pix.y.push(y); + minx = Math.min(minx, x); + maxx = Math.max(maxx, x); + miny = Math.min(miny, y); + maxy = Math.max(maxy, y); } } } - // ...need to learn what this part does too :badpokerface: - // is this just determining the boundaries of non-transparent pixel data? - pix.x.sort(function (a, b) { - return a - b; - }); - pix.y.sort(function (a, b) { - return a - b; - }); - var n = pix.x.length - 1; - w = pix.x[n] - pix.x[0] + 1; - h = pix.y[n] - pix.y[0] + 1; - // yup sure looks like it - try { - var cut = sourceCanvas - .getContext("2d") - .getImageData(pix.x[0], pix.y[0], w, h); - var cutCanvas = document.createElement("canvas"); - cutCanvas.width = w; - cutCanvas.height = h; - cutCanvas.getContext("2d").putImageData(cut, 0, 0); - } catch (ex) { - // probably empty image - //TODO confirm edge cases? - cutCanvas = null; - } - return cutCanvas; + bb.x = minx - options.border; + bb.y = miny - options.border; + bb.w = maxx - minx + 2 * options.border; + bb.h = maxy - miny + 2 * options.border; + + if (maxx < 0) throw new NoContentError("Canvas has no content to crop"); + + var cutCanvas = document.createElement("canvas"); + cutCanvas.width = bb.w; + cutCanvas.height = bb.h; + cutCanvas + .getContext("2d") + .drawImage(sourceCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + return {canvas: cutCanvas, bb}; } /** @@ -276,7 +277,7 @@ function downloadCanvas(options = {}) { if (options.filename) link.download = options.filename; var croppedCanvas = options.cropToContent - ? cropCanvas(options.canvas) + ? cropCanvas(options.canvas).canvas : options.canvas; if (croppedCanvas != null) { link.href = croppedCanvas.toDataURL("image/png"); diff --git a/js/ui/tool/colorbrush.js b/js/ui/tool/colorbrush.js new file mode 100644 index 0000000..09630b3 --- /dev/null +++ b/js/ui/tool/colorbrush.js @@ -0,0 +1,247 @@ +const _color_brush_draw_callback = (evn, state) => { + const ctx = state.drawLayer.ctx; + + ctx.strokeStyle = state.color; + + ctx.filter = "blur(" + state.brushBlur + "px)"; + ctx.lineWidth = state.brushSize; + ctx.beginPath(); + ctx.moveTo( + evn.px === undefined ? evn.x : evn.px, + evn.py === undefined ? evn.y : evn.py + ); + ctx.lineTo(evn.x, evn.y); + ctx.lineJoin = ctx.lineCap = "round"; + ctx.stroke(); +}; + +const _color_brush_erase_callback = (evn, state, ctx) => { + ctx.strokeStyle = "black"; + + ctx.lineWidth = state.brushSize; + ctx.beginPath(); + ctx.moveTo( + evn.px === undefined ? evn.x : evn.px, + evn.py === undefined ? evn.y : evn.py + ); + ctx.lineTo(evn.x, evn.y); + ctx.lineJoin = ctx.lineCap = "round"; + ctx.stroke(); +}; + +const colorBrushTool = () => + toolbar.registerTool( + "res/icons/brush.svg", + "Color Brush", + (state, opt) => { + // Draw new cursor immediately + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb({...mouse.coords.world.pos}); + state.drawLayer = imageCollection.registerLayer(null, { + after: imgLayer, + }); + state.eraseLayer = imageCollection.registerLayer(null, { + after: imgLayer, + }); + state.eraseLayer.canvas.style.display = "none"; + state.eraseBackup = imageCollection.registerLayer(null, { + after: imgLayer, + }); + state.eraseBackup.canvas.style.display = "none"; + + // Start Listeners + mouse.listen.world.onmousemove.on(state.movecb); + mouse.listen.world.onwheel.on(state.wheelcb); + + mouse.listen.world.btn.left.onpaintstart.on(state.drawstartcb); + mouse.listen.world.btn.left.onpaint.on(state.drawcb); + mouse.listen.world.btn.left.onpaintend.on(state.drawendcb); + + mouse.listen.world.btn.right.onpaintstart.on(state.erasestartcb); + mouse.listen.world.btn.right.onpaint.on(state.erasecb); + mouse.listen.world.btn.right.onpaintend.on(state.eraseendcb); + + // Display Color + setMask("none"); + }, + (state, opt) => { + // Clear Listeners + mouse.listen.world.onmousemove.clear(state.movecb); + mouse.listen.world.onwheel.clear(state.wheelcb); + + mouse.listen.world.btn.left.onpaintstart.clear(state.drawstartcb); + mouse.listen.world.btn.left.onpaint.clear(state.drawcb); + mouse.listen.world.btn.left.onpaintend.clear(state.drawendcb); + + mouse.listen.world.btn.right.onpaintstart.clear(state.erasestartcb); + mouse.listen.world.btn.right.onpaint.clear(state.erasecb); + mouse.listen.world.btn.right.onpaintend.clear(state.eraseendcb); + + // Delete layer + imageCollection.deleteLayer(state.drawLayer); + imageCollection.deleteLayer(state.eraseBackup); + imageCollection.deleteLayer(state.eraseLayer); + }, + { + init: (state) => { + state.config = { + brushScrollSpeed: 1 / 5, + minBrushSize: 2, + maxBrushSize: 500, + minBlur: 0, + maxBlur: 30, + }; + + state.color = "#FFFFFF"; + state.brushSize = 32; + state.brushBlur = 0; + state.affectMask = true; + state.setBrushSize = (size) => { + state.brushSize = size; + state.ctxmenu.brushSizeRange.value = size; + state.ctxmenu.brushSizeText.value = size; + }; + + state.movecb = (evn) => { + // draw big translucent white blob cursor + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + ovCtx.beginPath(); + ovCtx.arc(evn.x, evn.y, state.brushSize / 2, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line??? + ovCtx.fillStyle = state.color + "50"; + ovCtx.fill(); + }; + + state.wheelcb = (evn) => { + if (!evn.evn.ctrlKey) { + state.brushSize = state.setBrushSize( + state.brushSize - + Math.floor(state.config.brushScrollSpeed * evn.delta) + ); + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb(evn); + } + }; + + state.drawstartcb = (evn) => { + if (state.affectMask) _mask_brush_draw_callback(evn, state); + _color_brush_draw_callback(evn, state); + }; + + state.drawcb = (evn) => { + if (state.affectMask) _mask_brush_draw_callback(evn, state); + _color_brush_draw_callback(evn, state); + }; + + state.drawendcb = (evn) => { + const canvas = state.drawLayer.canvas; + const ctx = state.drawLayer.ctx; + + const cropped = cropCanvas(canvas, {border: 10}); + const bb = cropped.bb; + commands.runCommand("drawImage", "Color Brush Draw", { + image: cropped.canvas, + ...bb, + }); + + ctx.clearRect(bb.x, bb.y, bb.w, bb.h); + }; + + state.erasestartcb = (evn) => { + if (state.affectMask) _mask_brush_erase_callback(evn, state); + + // Make a backup of the current image to apply erase later + const bkpcanvas = state.eraseBackup.canvas; + const bkpctx = state.eraseBackup.ctx; + bkpctx.clearRect(0, 0, bkpcanvas.width, bkpcanvas.height); + bkpctx.drawImage(imgCanvas, 0, 0); + + imgCtx.globalCompositeOperation = "destination-out"; + _color_brush_erase_callback(evn, state, imgCtx); + imgCtx.globalCompositeOperation = "source-over"; + _color_brush_erase_callback(evn, state, state.eraseLayer.ctx); + }; + + state.erasecb = (evn) => { + if (state.affectMask) _mask_brush_erase_callback(evn, state); + imgCtx.globalCompositeOperation = "destination-out"; + _color_brush_erase_callback(evn, state, imgCtx); + imgCtx.globalCompositeOperation = "source-over"; + _color_brush_erase_callback(evn, state, state.eraseLayer.ctx); + }; + + state.eraseendcb = (evn) => { + const canvas = state.eraseLayer.canvas; + const ctx = state.eraseLayer.ctx; + + const bkpcanvas = state.eraseBackup.canvas; + + const cropped = cropCanvas(canvas, {border: 10}); + const bb = cropped.bb; + + imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height); + imgCtx.drawImage(bkpcanvas, 0, 0); + + commands.runCommand("eraseImage", "Color Brush Erase", { + mask: cropped.canvas, + ...bb, + }); + + ctx.clearRect(bb.x, bb.y, bb.w, bb.h); + }; + }, + populateContextMenu: (menu, state) => { + if (!state.ctxmenu) { + state.ctxmenu = {}; + + // Affects Mask Checkbox + const affectMaskCheckbox = _toolbar_input.checkbox( + state, + "affectMask", + "Affect Mask" + ).label; + + state.ctxmenu.affectMaskCheckbox = affectMaskCheckbox; + + // Brush size slider + const brushSizeSlider = _toolbar_input.slider( + state, + "brushSize", + "Brush Size", + state.config.minBrushSize, + state.config.maxBrushSize, + 1 + ); + state.ctxmenu.brushSizeSlider = brushSizeSlider.slider; + state.setBrushSize = brushSizeSlider.setValue; + + // Brush size slider + const brushBlurSlider = _toolbar_input.slider( + state, + "brushBlur", + "Brush Blur", + state.config.minBlur, + state.config.maxBlur, + 1 + ); + state.ctxmenu.brushBlurSlider = brushBlurSlider.slider; + + // Brush color + const brushColorPicker = document.createElement("input"); + brushColorPicker.type = "color"; + brushColorPicker.style.width = "100%"; + brushColorPicker.value = state.color; + brushColorPicker.addEventListener("input", (evn) => { + state.color = evn.target.value; + }); + + state.ctxmenu.brushColorPicker = brushColorPicker; + } + + menu.appendChild(state.ctxmenu.affectMaskCheckbox); + menu.appendChild(state.ctxmenu.brushSizeSlider); + menu.appendChild(state.ctxmenu.brushBlurSlider); + menu.appendChild(state.ctxmenu.brushColorPicker); + }, + shortcut: "C", + } + ); diff --git a/res/icons/brush.svg b/res/icons/brush.svg new file mode 100644 index 0000000..a4ddbd1 --- /dev/null +++ b/res/icons/brush.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file