diff --git a/.github/workflows/autoformat.yml b/.github/workflows/autoformat.yml new file mode 100644 index 0000000..1d33393 --- /dev/null +++ b/.github/workflows/autoformat.yml @@ -0,0 +1,19 @@ +name: Prettier Autoformatting +on: + pull_request: + push: + branches: + - main +jobs: + prettier: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - name: Prettify + uses: creyD/prettier_action@v4.2 + with: + prettier_options: --write **/*.{js,html,css,md} diff --git a/css/colors.css b/css/colors.css new file mode 100644 index 0000000..1dba78a --- /dev/null +++ b/css/colors.css @@ -0,0 +1,8 @@ +:root { + --c-primary: #2c3333; + --c-hover: hsl(180, 7%, 30%); + --c-active: hsl(180, 7%, 25%); + --c-secondary: #395b64; + --c-accent: #a5c9ca; + --c-text: #e7f6f2; +} diff --git a/css/index.css b/css/index.css index b0485fe..d32ed36 100644 --- a/css/index.css +++ b/css/index.css @@ -14,44 +14,6 @@ body { background-color: #ccc; } -#historyContainer > .info { - padding: 0; -} - -#history.history { - height: 200px; - overflow: scroll; -} - -#history.history > .history-item { - cursor: pointer; - - padding: 5px; - padding-top: 2px; - padding-bottom: 2px; -} - -#history.history > .history-item { - background-color: #0000; -} -#history.history > .history-item:hover { - background-color: #fff5; -} - -#history.history > .history-item.current { - background-color: #66f5; -} -#history.history > .history-item.current:hover { - background-color: #66f5; -} - -#history.history > .history-item.future { - background-color: #4445; -} -#history.history > .history-item.future:hover { - background-color: #ddd5; -} - .mainHSplit { display: grid; grid-template-columns: 1fr; @@ -68,40 +30,6 @@ body { grid-row-gap: 5px; } -.uiContainer { - position: fixed; - width: 250px; - height: auto; - z-index: 999; -} - -.uiTitleBar { - z-index: 999; - cursor: move; - background-color: rgba(104, 104, 104, 0.75); - z-index: 999; - - user-select: none; - - padding-left: 5px; - padding-right: 5px; - padding-top: 5px; - padding-bottom: 5px; - margin-bottom: auto; - font-size: 1.5em; - color: black; - text-align: center; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - border: solid; - border-bottom: none; - border-color: black; -} - -.draggable { - cursor: move; -} - .toolbar { display: flex; justify-content: space-between; @@ -158,7 +86,7 @@ button.tool:hover { transition: max-height 0.2s ease-out; } -.info { +.menu-container { background-color: rgba(255, 255, 255, 0.5); padding-left: 10px; padding-right: 10px; diff --git a/css/ui/generic.css b/css/ui/generic.css new file mode 100644 index 0000000..b8c4556 --- /dev/null +++ b/css/ui/generic.css @@ -0,0 +1,32 @@ +/* UI Floating Windows */ +.floating-window { + position: fixed; + width: 250px; + height: auto; + z-index: 999; +} + +.floating-window-title { + cursor: move; + background-color: rgba(104, 104, 104, 0.75); + + user-select: none; + + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + padding-bottom: 5px; + margin-bottom: auto; + font-size: 1.5em; + color: black; + text-align: center; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + border: solid; + border-bottom: none; + border-color: black; +} + +.draggable { + cursor: move; +} diff --git a/css/ui/history.css b/css/ui/history.css new file mode 100644 index 0000000..0c1834f --- /dev/null +++ b/css/ui/history.css @@ -0,0 +1,38 @@ +#historyContainer > .info { + padding: 0; +} + +#history.history { + height: 200px; + overflow-y: scroll; + overflow-x: hidden; +} + +#history.history > .history-item { + cursor: pointer; + + padding: 5px; + padding-top: 2px; + padding-bottom: 2px; +} + +#history.history > .history-item { + background-color: #0000; +} +#history.history > .history-item:hover { + background-color: #fff5; +} + +#history.history > .history-item.current { + background-color: #66f5; +} +#history.history > .history-item.current:hover { + background-color: #66f5; +} + +#history.history > .history-item.future { + background-color: #4445; +} +#history.history > .history-item.future:hover { + background-color: #ddd5; +} diff --git a/css/ui/toolbar.css b/css/ui/toolbar.css new file mode 100644 index 0000000..631a3ed --- /dev/null +++ b/css/ui/toolbar.css @@ -0,0 +1,62 @@ +#ui-toolbar { + align-content: center; + + width: 60px; + + border-radius: 5px; + + color: var(--c-text); + background-color: var(--c-primary); +} + +#ui-toolbar .handle { + display: flex; + align-items: center; + justify-content: center; + + height: 10px; +} + +#ui-toolbar .handle > .line { + width: 80%; + border-top: 2px #777 dotted; +} + +#ui-toolbar .tool .tool-icon { + filter: invert(60%); +} +#ui-toolbar .tool.using .tool-icon { + filter: invert(80%); +} +#ui-toolbar .tool:hover .tool-icon { + filter: invert(90%); +} + +/* The separator */ +#ui-toolbar .separator { + width: 80%; + margin: auto; + align-self: center; + border-top: 1px var(--c-hover) solid; +} + +/* Styles for the tool buttons */ +#ui-toolbar .tool { + display: flex; + align-items: center; + justify-content: center; + + aspect-ratio: 1; + margin: 5px; + border-radius: 5px; + + cursor: pointer; +} + +#ui-toolbar .tool.using { + background-color: var(--c-active); +} + +#ui-toolbar .tool:hover { + background-color: var(--c-hover); +} diff --git a/index.html b/index.html index 70cb933..40cd321 100644 --- a/index.html +++ b/index.html @@ -1,199 +1,341 @@ + + + openOutpaint 🐠 + + - - - openOutpaint 🐠 - - - + + - - -
-
openOutpaint 🐠
-
+ + - -
+ + - - -
-
-
-
-
-
-
- - -
-
-
- -
-
-
- -
-
-
-
-
-
-
-
- -
-
- -
- -
- -
+ + +
+
+ openOutpaint 🐠 +
+ + + +
+
+
+
+
+
+
+ + +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+ +
+ +
+ +
+
+ + +
+ +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+ Alpha release v0.0.6.6 +
+
+
+
+
+ Context Menu +
+
+
+
+
-
+ +
+
History
+ +
- -
-
History
-
-
-
- - -
-
+ +
+
+ +
+
+
-
-
-
-
- - -

lol ur browser sucks

-
- - -

lol ur browser sucks

-
- - -

lol ur browser sucks

-
- - -

lol ur browser sucks

-
- - -

lol ur browser sucks

-
- - -

lol ur browser sucks

-
-
- -
- -
-
-
-
- +
+
+
+ + +

lol ur browser sucks

+
+ + +

lol ur browser sucks

+
+ + +

lol ur browser sucks

+
+ + +

lol ur browser sucks

+
+ + +

lol ur browser sucks

+
+ + +

lol ur browser sucks

+
+
+ +
+
+
+
+
+ - -

lol ur browser sucks

-

- -

lol ur browser sucks

-

-
-
-
+ +

lol ur browser sucks


+ +

lol ur browser sucks


+
+
+
+ + + + + + - - - - - - - - - + + + - \ No newline at end of file + + + + + + + diff --git a/js/commands.js b/js/commands.js index 304638a..fb6e73e 100644 --- a/js/commands.js +++ b/js/commands.js @@ -156,3 +156,53 @@ commands.createCommand( state.context.drawImage(state.original, state.box.x, state.box.y); } ); + +commands.createCommand( + "eraseImage", + (title, options, state) => { + if ( + !options || + options.x === undefined || + options.y === undefined || + options.w === undefined || + options.h === undefined + ) + throw "Command eraseImage requires options in the format: {x, y, w, h, ctx?}"; + + // Check if we have state + if (!state.context) { + const context = options.ctx || imgCtx; + state.context = context; + + // Saving what was in the canvas before the command + const imgData = context.getImageData( + options.x, + options.y, + options.w, + options.h + ); + state.box = { + x: options.x, + y: options.y, + w: options.w, + h: options.h, + }; + // Create Image + const cutout = document.createElement("canvas"); + cutout.width = state.box.w; + cutout.height = state.box.h; + cutout.getContext("2d").putImageData(imgData, 0, 0); + state.original = new Image(); + state.original.src = cutout.toDataURL(); + } + + // Apply command + state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h); + }, + (title, state) => { + // Clear destination area + state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h); + // Undo + state.context.drawImage(state.original, state.box.x, state.box.y); + } +); diff --git a/js/index.js b/js/index.js index a7592dd..1fd5163 100644 --- a/js/index.js +++ b/js/index.js @@ -104,11 +104,9 @@ var heldButton = 0; var snapX = 0; var snapY = 0; var drawThis = {}; -var clicked = false; const basePixelCount = 64; //64 px - ALWAYS 64 PX var scaleFactor = 8; //x64 px var snapToGrid = true; -var paintMode = false; var backupMaskPaintCanvas; //??? var backupMaskPaintCtx; //...? look i am bad at this var backupMaskChunk = null; @@ -123,9 +121,8 @@ var arbitraryImageData; var arbitraryImageBitmap; var arbitraryImageBase64; // seriously js cmon work with me here var placingArbitraryImage = false; // for when the user has loaded an existing image from their computer -var enableErasing = false; // accidental right-click erase if the user isn't trying to erase is a bad thing var marchOffset = 0; -var marching = false; +var stopMarching = null; var marchCoords = {}; // info div, sometimes hidden @@ -156,7 +153,6 @@ function startup() { loadSettings(); drawBackground(); changeScaleFactor(); - changePaintMode(); changeSampler(); changeSteps(); changeCfgScale(); @@ -167,7 +163,6 @@ function startup() { changeSeed(); changeOverMaskPx(); changeHiResFix(); - changeEnableErasing(); document.getElementById("overlayCanvas").onmousemove = mouseMove; document.getElementById("overlayCanvas").onmousedown = mouseDown; document.getElementById("overlayCanvas").onmouseup = mouseUp; @@ -193,43 +188,56 @@ function writeArbitraryImage(img, x, y) { document.getElementById("preloadImage").files = null; } -function dream(x, y, prompt) { +function dream( + x, + y, + prompt, + extra = {method: endpoint, stopMarching: () => {}} +) { tmpImgXYWH.x = x; tmpImgXYWH.y = y; tmpImgXYWH.w = prompt.width; tmpImgXYWH.h = prompt.height; console.log( - "dreaming to " + host + url + endpoint + ":\r\n" + JSON.stringify(prompt) + "dreaming to " + + host + + url + + (extra.method || endpoint) + + ":\r\n" + + JSON.stringify(prompt) ); - postData(prompt).then((data) => { + postData(prompt, extra).then((data) => { returnedImages = data.images; totalImagesReturned = data.images.length; blockNewImages = true; //console.log(data); // JSON data parsed by `data.json()` call - imageAcceptReject(x, y, data); + imageAcceptReject(x, y, data, extra); }); } -async function postData(promptData) { +async function postData(promptData, extra = null) { this.host = document.getElementById("host").value; // Default options are marked with * - const response = await fetch(this.host + this.url + this.endpoint, { - method: "POST", // *GET, POST, PUT, DELETE, etc. - mode: "cors", // no-cors, *cors, same-origin - cache: "default", // *default, no-cache, reload, force-cache, only-if-cached - credentials: "same-origin", // include, *same-origin, omit - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - redirect: "follow", // manual, *follow, error - referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url - body: JSON.stringify(promptData), // body data type must match "Content-Type" header - }); + const response = await fetch( + this.host + this.url + extra.method || endpoint, + { + method: "POST", // *GET, POST, PUT, DELETE, etc. + mode: "cors", // no-cors, *cors, same-origin + cache: "default", // *default, no-cache, reload, force-cache, only-if-cached + credentials: "same-origin", // include, *same-origin, omit + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + redirect: "follow", // manual, *follow, error + referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + body: JSON.stringify(promptData), // body data type must match "Content-Type" header + } + ); return response.json(); // parses JSON response into native JavaScript objects } -function imageAcceptReject(x, y, data) { +function imageAcceptReject(x, y, data, extra = null) { const img = new Image(); img.onload = function () { tempCtx.drawImage(img, x, y); //imgCtx for actual image, tmp for... holding? @@ -254,7 +262,8 @@ function imageAcceptReject(x, y, data) { function accept(evt) { // write image to imgcanvas - marching = false; + stopMarching && stopMarching(); + stopMarching = null; clearBackupMask(); placeImage(); removeChoiceButtons(); @@ -264,7 +273,8 @@ function accept(evt) { function reject(evt) { // remove image entirely - marching = false; + stopMarching && stopMarching(); + stopMarching = null; restoreBackupMask(); clearBackupMask(); clearTargetMask(); @@ -368,39 +378,23 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function snap(i, scaled = true) { - // very cheap test proof of concept but it works surprisingly well - var scaleOffset = 0; - if (scaled) { - if (scaleFactor % 2 != 0) { - // odd number, snaps to center of cell, oops - scaleOffset = basePixelCount / 2; - } - } - var snapOffset = (i % basePixelCount) - scaleOffset; - if (snapOffset == 0) { - return snapOffset; - } - return -snapOffset; +function march(bb) { + let offset = 0; + + const interval = setInterval(() => { + drawMarchingAnts(bb, offset++); + offset %= 16; + }, 20); + + return () => clearInterval(interval); } -function march() { - if (marching) { - marchOffset++; - if (marchOffset > 16) { - marchOffset = 0; - } - drawMarchingAnts(); - setTimeout(march, 20); - } -} - -function drawMarchingAnts() { +function drawMarchingAnts(bb, offset) { clearTargetMask(); tgtCtx.strokeStyle = "#FFFFFFFF"; //"#55000077"; tgtCtx.setLineDash([4, 2]); - tgtCtx.lineDashOffset = -marchOffset; - tgtCtx.strokeRect(marchCoords.x, marchCoords.y, marchCoords.w, marchCoords.h); + tgtCtx.lineDashOffset = -offset; + tgtCtx.strokeRect(bb.x, bb.y, bb.w, bb.h); } function mouseMove(evt) { @@ -427,67 +421,9 @@ function mouseMove(evt) { finalX = snapOffsetX + canvasX; finalY = snapOffsetY + canvasY; ovCtx.drawImage(arbitraryImage, finalX, finalY); - } else if (!paintMode) { - // draw targeting square reticle thingy cursor - ovCtx.strokeStyle = "#FFFFFF"; - snapOffsetX = 0; - snapOffsetY = 0; - if (snapToGrid) { - snapOffsetX = snap(canvasX); - snapOffsetY = snap(canvasY); - } - finalX = snapOffsetX + canvasX; - finalY = snapOffsetY + canvasY; - ovCtx.strokeRect( - parseInt(finalX - (basePixelCount * scaleFactor) / 2), - parseInt(finalY - (basePixelCount * scaleFactor) / 2), - basePixelCount * scaleFactor, - basePixelCount * scaleFactor - ); //origin is middle of the frame } } -/** - * Mask implementation - */ -mouse.listen.canvas.onmousemove.on((evn) => { - if (paintMode && evn.target.id === "overlayCanvas") { - // draw big translucent red blob cursor - ovCtx.beginPath(); - ovCtx.arc(evn.x, evn.y, 4 * scaleFactor, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line??? - ovCtx.fillStyle = "#FF6A6A50"; - ovCtx.fill(); - } -}); - -mouse.listen.canvas.left.onpaint.on((evn) => { - if (paintMode && evn.initialTarget.id === "overlayCanvas") { - maskPaintCtx.globalCompositeOperation = "source-over"; - maskPaintCtx.strokeStyle = "#FF6A6A"; - - maskPaintCtx.lineWidth = 8 * scaleFactor; - maskPaintCtx.beginPath(); - maskPaintCtx.moveTo(evn.px, evn.py); - maskPaintCtx.lineTo(evn.x, evn.y); - maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round"; - maskPaintCtx.stroke(); - } -}); - -mouse.listen.canvas.right.onpaint.on((evn) => { - if (paintMode && evn.initialTarget.id === "overlayCanvas") { - maskPaintCtx.globalCompositeOperation = "destination-out"; - maskPaintCtx.strokeStyle = "#FFFFFFFF"; - - maskPaintCtx.lineWidth = 8 * scaleFactor; - maskPaintCtx.beginPath(); - maskPaintCtx.moveTo(evn.px, evn.py); - maskPaintCtx.lineTo(evn.x, evn.y); - maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round"; - maskPaintCtx.stroke(); - } -}); - function mouseDown(evt) { const rect = ovCanvas.getBoundingClientRect(); var oddOffset = 0; @@ -503,39 +439,6 @@ function mouseDown(evt) { nextBox.w = arbitraryImageData.width; nextBox.h = arbitraryImageData.height; dropTargets.push(nextBox); - } else if (!paintMode) { - //const rect = ovCanvas.getBoundingClientRect() - var nextBox = {}; - nextBox.x = - evt.clientX - - (basePixelCount * scaleFactor) / 2 - - rect.left + - oddOffset; //origin is middle of the frame - nextBox.y = - evt.clientY - (basePixelCount * scaleFactor) / 2 - rect.top + oddOffset; //TODO make a way to set the origin to numpad dirs? - nextBox.w = basePixelCount * scaleFactor; - nextBox.h = basePixelCount * scaleFactor; - drawTargets.push(nextBox); - } - } else if (evt.button == 2) { - if (enableErasing && !paintMode) { - // right click, also gotta make sure mask blob isn't being used as it's visually inconsistent with behavior of erased region - ctx = imgCanvas.getContext("2d"); - if (snapToGrid) { - ctx.clearRect( - canvasX + snap(canvasX) - (basePixelCount * scaleFactor) / 2, - canvasY + snap(canvasY) - (basePixelCount * scaleFactor) / 2, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor - ); - } else { - ctx.clearRect( - canvasX - (basePixelCount * scaleFactor) / 2, - canvasY - (basePixelCount * scaleFactor) / 2, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor - ); - } } } } @@ -561,188 +464,10 @@ function mouseUp(evt) { drawThis.h = target.h; drawIt = drawThis; // i still think this is really stupid and redundant and unnecessary and redundant drop(drawIt); - } else if (paintMode) { - clicked = false; - return; - } else { - if (!blockNewImages) { - //TODO seriously, refactor this - blockNewImages = true; - marching = true; - var drawIt = {}; //why am i doing this???? - var target = drawTargets[drawTargets.length - 1]; //get the last one... why am i storing all of them? - var oddOffset = 0; - if (scaleFactor % 2 != 0) { - oddOffset = basePixelCount / 2; - } - snapOffsetX = 0; - snapOffsetY = 0; - if (snapToGrid) { - snapOffsetX = snap(target.x); - snapOffsetY = snap(target.y); - } - finalX = snapOffsetX + target.x - oddOffset; - finalY = snapOffsetY + target.y - oddOffset; - - drawThis.x = marchCoords.x = finalX; - drawThis.y = marchCoords.y = finalY; - drawThis.w = marchCoords.w = target.w; - drawThis.h = marchCoords.h = target.h; - march(finalX, finalY, target.w, target.h); - drawIt = drawThis; //TODO this is WRONG but also explicitly only draws the last image ... i think - //check if there's image data already there - // console.log(downX + ":" + downY + " :: " + this.isCanvasBlank(downX, downY)); - if (!isCanvasBlank(drawIt.x, drawIt.y, drawIt.w, drawIt.h, imgCanvas)) { - // image exists, set up for img2img - var mainCanvasCtx = document - .getElementById("canvas") - .getContext("2d"); - const imgChunk = mainCanvasCtx.getImageData( - drawIt.x, - drawIt.y, - drawIt.w, - drawIt.h - ); // imagedata object of the image being outpainted - const imgChunkData = imgChunk.data; // imagedata.data object, a big inconvenient uint8clampedarray - // these are the 3 mask monitors on the bottom of the page - var initImgCanvas = document.getElementById("initImgCanvasMonitor"); - var overMaskCanvas = document.getElementById("overMaskCanvasMonitor"); - overMaskCanvas.width = initImgCanvas.width = target.w; //maskCanvas.width = target.w; - overMaskCanvas.height = initImgCanvas.height = target.h; //maskCanvas.height = target.h; - var initImgCanvasCtx = initImgCanvas.getContext("2d"); - var overMaskCanvasCtx = overMaskCanvas.getContext("2d"); - // get blank pixels to use as mask - const initImgData = mainCanvasCtx.createImageData(drawIt.w, drawIt.h); - let overMaskImgData = overMaskCanvasCtx.createImageData( - drawIt.w, - drawIt.h - ); - // cover entire masks in black before adding masked areas - - for (let i = 0; i < imgChunkData.length; i += 4) { - // l->r, top->bottom, R G B A pixel values in a big ol array - // make a simple mask - if (imgChunkData[i + 3] == 0) { - // rgba pixel values, 4th one is alpha, if it's 0 there's "nothing there" in the image display canvas and its time to outpaint - overMaskImgData.data[i] = 255; // white mask gets painted over - overMaskImgData.data[i + 1] = 255; - overMaskImgData.data[i + 2] = 255; - overMaskImgData.data[i + 3] = 255; - - initImgData.data[i] = 0; // null area on initial image becomes opaque black pixels - initImgData.data[i + 1] = 0; - initImgData.data[i + 2] = 0; - initImgData.data[i + 3] = 255; - } else { - // leave these pixels alone - overMaskImgData.data[i] = 0; // black mask gets ignored for in/outpainting - overMaskImgData.data[i + 1] = 0; - overMaskImgData.data[i + 2] = 0; - overMaskImgData.data[i + 3] = 255; // but it still needs an opaque alpha channel - - initImgData.data[i] = imgChunkData[i]; // put the original picture back in the painted area - initImgData.data[i + 1] = imgChunkData[i + 1]; - initImgData.data[i + 2] = imgChunkData[i + 2]; - initImgData.data[i + 3] = imgChunkData[i + 3]; //it's still RGBA so we can handily do this in nice chunks'o'4 - } - } - if (overMaskPx > 0) { - // https://stackoverflow.com/a/30204783 ???? !!!!!!!! - overMaskCanvasCtx.fillStyle = "black"; - overMaskCanvasCtx.fillRect(0, 0, drawIt.w, drawIt.h); // fill with black instead of null to start - for (i = 0; i < overMaskImgData.data.length; i += 4) { - if (overMaskImgData.data[i] == 255) { - // white pixel? - // just blotch all over the thing - var rando = Math.floor(Math.random() * overMaskPx); - overMaskCanvasCtx.beginPath(); - overMaskCanvasCtx.arc( - (i / 4) % overMaskCanvas.width, - Math.floor(i / 4 / overMaskCanvas.width), - scaleFactor + rando, // was 4 * sf + rando, too big - 0, - 2 * Math.PI, - true - ); - overMaskCanvasCtx.fillStyle = "#FFFFFFFF"; - overMaskCanvasCtx.fill(); - } - } - overMaskImgData = overMaskCanvasCtx.getImageData( - 0, - 0, - overMaskCanvas.width, - overMaskCanvas.height - ); - overMaskCanvasCtx.putImageData(overMaskImgData, 0, 0); - } - // also check for painted masks in region, add them as white pixels to mask canvas - const maskChunk = maskPaintCtx.getImageData( - drawIt.x, - drawIt.y, - drawIt.w, - drawIt.h - ); - const maskChunkData = maskChunk.data; - for (let i = 0; i < maskChunkData.length; i += 4) { - if (maskChunkData[i + 3] != 0) { - overMaskImgData.data[i] = 255; - overMaskImgData.data[i + 1] = 255; - overMaskImgData.data[i + 2] = 255; - overMaskImgData.data[i + 3] = 255; - } - } - // backup any painted masks ingested then them, replacable if user doesn't like resultant image - var clearArea = maskPaintCtx.createImageData(drawIt.w, drawIt.h); - backupMaskChunk = maskChunk; - backupMaskX = drawIt.x; - backupMaskY = drawIt.y; - - var clearD = clearArea.data; - for (let i = 0; i < clearD.length; i++) { - clearD[i] = 0; // just null it all out - } - maskPaintCtx.putImageData(clearArea, drawIt.x, drawIt.y); - // mask monitors - overMaskCanvasCtx.putImageData(overMaskImgData, 0, 0); // :pray: - var overMaskBase64 = overMaskCanvas.toDataURL(); - initImgCanvasCtx.putImageData(initImgData, 0, 0); - var initImgBase64 = initImgCanvas.toDataURL(); - // anyway all that to say NOW let's run img2img - endpoint = "img2img"; - stableDiffusionData.mask = overMaskBase64; - stableDiffusionData.init_images = [initImgBase64]; - // slightly more involved than txt2img - } else { - // time to run txt2img - endpoint = "txt2img"; - // easy enough - } - stableDiffusionData.prompt = document.getElementById("prompt").value; - stableDiffusionData.negative_prompt = - document.getElementById("negPrompt").value; - stableDiffusionData.width = drawIt.w; - stableDiffusionData.height = drawIt.h; - stableDiffusionData.firstphase_height = drawIt.h / 2; - stableDiffusionData.firstphase_width = drawIt.w / 2; - dream(drawIt.x, drawIt.y, stableDiffusionData); - } } } } -function changePaintMode() { - paintMode = document.getElementById("cbxPaint").checked; - clearTargetMask(); - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); -} - -function changeEnableErasing() { - // yeah because this is for the image layer - enableErasing = document.getElementById("cbxEnableErasing").checked; - localStorage.setItem("enable_erase", enableErasing); -} - function changeSampler() { stableDiffusionData.sampler_index = document.getElementById("samplerSelect").value; @@ -972,6 +697,5 @@ function loadSettings() { document.getElementById("maskBlur").value = Number(_mask_blur); document.getElementById("seed").value = Number(_seed); document.getElementById("cbxHRFix").checked = Boolean(_enable_hr); - document.getElementById("cbxEnableErasing").checked = Boolean(_enable_erase); document.getElementById("overMaskPx").value = Number(_overmask_px); } diff --git a/js/input.js b/js/input.js index 3cb4d5d..14e66bc 100644 --- a/js/input.js +++ b/js/input.js @@ -31,9 +31,9 @@ function _context_coords() { } function _mouse_observers() { return { - // Simple click handlers + // Simple click handler onclick: new Observer(), - // Double click handlers (will still trigger simple click handler as well) + // Double click handler (will still trigger simple click handler as well) ondclick: new Observer(), // Drag handler ondragstart: new Observer(), @@ -48,6 +48,7 @@ function _mouse_observers() { function _context_observers() { return { + onwheel: new Observer(), onmousemove: new Observer(), left: _mouse_observers(), middle: _mouse_observers(), @@ -270,36 +271,27 @@ window.onmousemove = (evn) => { }); }); }; -/** MOUSE DEBUG */ -/* -mouse.listen.window.right.onclick.on(() => - console.debug('mouse.listen.window.right.onclick') -); -mouse.listen.window.right.ondclick.on(() => - console.debug('mouse.listen.window.right.ondclick') +window.addEventListener( + "wheel", + (evn) => { + evn.preventDefault(); + ["window", "canvas", "world"].forEach((ctx) => { + mouse.listen[ctx].onwheel.emit({ + target: evn.target, + delta: evn.deltaY, + deltaX: evn.deltaX, + deltaY: evn.deltaY, + deltaZ: evn.deltaZ, + mode: evn.deltaMode, + x: mouse[ctx].pos.x, + y: mouse[ctx].pos.y, + timestamp: new Date(), + }); + }); + }, + {passive: false} ); -mouse.listen.window.right.ondragstart.on(() => - console.debug('mouse.listen.window.right.ondragstart') -); -mouse.listen.window.right.ondrag.on(() => - console.debug('mouse.listen.window.right.ondrag') -); -mouse.listen.window.right.ondragend.on(() => - console.debug('mouse.listen.window.right.ondragend') -); - -mouse.listen.window.right.onpaintstart.on(() => - console.debug('mouse.listen.window.right.onpaintstart') -); -mouse.listen.window.right.onpaint.on(() => - console.debug('mouse.listen.window.right.onpaint') -); -mouse.listen.window.right.onpaintend.on(() => - console.debug('mouse.listen.window.right.onpaintend') -); -*/ - /** * Keyboard input processing */ diff --git a/js/settingsbar.js b/js/settingsbar.js index 6a4aec6..bfb60c3 100644 --- a/js/settingsbar.js +++ b/js/settingsbar.js @@ -1,40 +1,4 @@ -//dragElement(document.getElementById("infoContainer")); -//dragElement(document.getElementById("historyContainer")); - -function dragElement(elmnt) { - var p3 = 0, - p4 = 0; - var draggableElements = elmnt.getElementsByClassName("draggable"); - for (var i = 0; i < draggableElements.length; i++) { - draggableElements[i].onmousedown = dragMouseDown; - } - - function dragMouseDown(e) { - e.preventDefault(); - p3 = e.clientX; - p4 = e.clientY; - document.onmouseup = closeDragElement; - document.onmousemove = elementDrag; - } - - function elementDrag(e) { - e.preventDefault(); - elmnt.style.bottom = null; - elmnt.style.right = null; - elmnt.style.top = elmnt.offsetTop - (p4 - e.clientY) + "px"; - elmnt.style.left = elmnt.offsetLeft - (p3 - e.clientX) + "px"; - p3 = e.clientX; - p4 = e.clientY; - } - - function closeDragElement() { - document.onmouseup = null; - document.onmousemove = null; - } -} - -function makeDraggable(id) { - const element = document.getElementById(id); +function makeDraggable(element) { const startbb = element.getBoundingClientRect(); let dragging = false; let offset = {x: 0, y: 0}; @@ -66,7 +30,9 @@ function makeDraggable(id) { }); } -makeDraggable("infoContainer"); +document.querySelectorAll(".floating-window").forEach((w) => { + makeDraggable(w); +}); var coll = document.getElementsByClassName("collapsible"); for (var i = 0; i < coll.length; i++) { diff --git a/js/shortcuts.js b/js/shortcuts.js index 5397d55..b2cd603 100644 --- a/js/shortcuts.js +++ b/js/shortcuts.js @@ -6,3 +6,11 @@ keyboard.onShortcut({ctrl: true, key: "KeyZ"}, () => { keyboard.onShortcut({ctrl: true, key: "KeyY"}, () => { commands.redo(); }); + +// Tool shortcuts +keyboard.onShortcut({key: "KeyD"}, () => { + tools.dream.enable(); +}); +keyboard.onShortcut({key: "KeyM"}, () => { + tools.maskbrush.enable(); +}); diff --git a/js/ui/history.js b/js/ui/history.js index fb03c40..2a2018b 100644 --- a/js/ui/history.js +++ b/js/ui/history.js @@ -1,6 +1,4 @@ (() => { - makeDraggable("historyContainer"); - const historyView = document.getElementById("history"); const makeHistoryEntry = (index, id, title) => { diff --git a/js/ui/tool/dream.js b/js/ui/tool/dream.js new file mode 100644 index 0000000..a283173 --- /dev/null +++ b/js/ui/tool/dream.js @@ -0,0 +1,87 @@ +const dream_generate_callback = (evn, state) => { + if (evn.target.id === "overlayCanvas" && !blockNewImages) { + const bb = getBoundingBox( + evn.x, + evn.y, + basePixelCount * scaleFactor, + basePixelCount * scaleFactor, + state.snapToGrid && basePixelCount + ); + + // Build request to the API + const request = {}; + Object.assign(request, stableDiffusionData); + + // Load prompt (maybe we should add some events so we don't have to do this) + request.prompt = document.getElementById("prompt").value; + request.negative_prompt = document.getElementById("negPrompt").value; + + // Don't allow another image until is finished + blockNewImages = true; + + // Setup marching ants + stopMarching = march(bb); + + // Setup some basic information for SD + request.width = bb.w; + request.height = bb.h; + + request.firstphase_width = bb.w / 2; + request.firstphase_height = bb.h / 2; + + // Use txt2img if canvas is blank + if (isCanvasBlank(bb.x, bb.y, bb.w, bb.h, imgCanvas)) { + // Dream + dream(bb.x, bb.y, request, {method: "txt2img"}); + } else { + // Use img2img if not + + // Temporary canvas for init image and mask generation + const auxCanvas = document.createElement("canvas"); + auxCanvas.width = request.width; + auxCanvas.height = request.height; + const auxCtx = auxCanvas.getContext("2d"); + + auxCtx.fillStyle = "#000F"; + + // Get init image + auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + request.init_images = [auxCanvas.toDataURL()]; + + // Get mask image + auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.globalCompositeOperation = "destination-in"; + auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + auxCtx.globalCompositeOperation = "destination-out"; + auxCtx.drawImage( + maskPaintCanvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + bb.w, + bb.h + ); + auxCtx.globalCompositeOperation = "destination-atop"; + auxCtx.fillStyle = "#FFFF"; + auxCtx.fillRect(0, 0, bb.w, bb.h); + request.mask = auxCanvas.toDataURL(); + + // Dream + dream(bb.x, bb.y, request, {method: "img2img"}); + } + } +}; +const dream_erase_callback = (evn, state) => { + const bb = getBoundingBox( + evn.x, + evn.y, + basePixelCount * scaleFactor, + basePixelCount * scaleFactor, + state.snapToGrid && basePixelCount + ); + commands.runCommand("eraseImage", "Erase Area", bb); +}; diff --git a/js/ui/tool/maskbrush.js b/js/ui/tool/maskbrush.js new file mode 100644 index 0000000..bc3a3c5 --- /dev/null +++ b/js/ui/tool/maskbrush.js @@ -0,0 +1,27 @@ +const mask_brush_draw_callback = (evn, state) => { + if (evn.initialTarget.id === "overlayCanvas") { + maskPaintCtx.globalCompositeOperation = "source-over"; + maskPaintCtx.strokeStyle = "#FF6A6A"; + + maskPaintCtx.lineWidth = state.brushSize; + maskPaintCtx.beginPath(); + maskPaintCtx.moveTo(evn.px, evn.py); + maskPaintCtx.lineTo(evn.x, evn.y); + maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round"; + maskPaintCtx.stroke(); + } +}; + +const mask_brush_erase_callback = (evn, state) => { + if (evn.initialTarget.id === "overlayCanvas") { + maskPaintCtx.globalCompositeOperation = "destination-out"; + maskPaintCtx.strokeStyle = "#FFFFFFFF"; + + maskPaintCtx.lineWidth = state.brushSize; + maskPaintCtx.beginPath(); + maskPaintCtx.moveTo(evn.px, evn.py); + maskPaintCtx.lineTo(evn.x, evn.y); + maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round"; + maskPaintCtx.stroke(); + } +}; diff --git a/js/ui/toolbar.js b/js/ui/toolbar.js new file mode 100644 index 0000000..5987028 --- /dev/null +++ b/js/ui/toolbar.js @@ -0,0 +1,300 @@ +/** + * Toolbar + */ + +const toolbar = { + _toolbar: document.getElementById("ui-toolbar"), + + tools: [], + + _makeToolbarEntry: (tool) => { + const toolTitle = document.createElement("img"); + toolTitle.classList.add("tool-icon"); + toolTitle.src = tool.icon; + + const toolEl = document.createElement("div"); + toolEl.id = `tool-${tool.id}`; + toolEl.classList.add("tool"); + toolEl.title = tool.name; + if (tool.options.shortcut) toolEl.title += ` (${tool.options.shortcut})`; + toolEl.onclick = () => tool.enable(); + + toolEl.appendChild(toolTitle); + + return toolEl; + }, + + registerTool( + icon, + toolname, + enable, + disable, + options = { + /** + * Runs on tool creation. It receives the tool state. + * + * Can be used to setup callback functions, for example. + */ + init: null, + /** + * Function to populate the state menu. + * + * It receives a div element (that is the menu) and the current tool state. + */ + populateContextMenu: null, + /** + * Help description of the tool; for now used for nothing + */ + description: "", + /** + * Shortcut; Text describing this tool's shortcut access + */ + shortcut: "", + } + ) { + // Set some defaults + if (!options.init) + options.init = (state) => console.debug(`Initialized tool '${toolname}'`); + + if (!options.populateContextMenu) + options.populateContextMenu = (menu, state) => { + const span = document.createElement("span"); + span.textContent = "Tool has no context menu"; + menu.appendChild(span); + return; + }; + + // Create tool + const id = guid(); + + const contextMenuEl = document.getElementById("tool-context"); + + const tool = { + id, + icon, + name: toolname, + enabled: false, + _element: null, + state: {}, + options, + enable: (opt = null) => { + this.tools.filter((t) => t.enabled).forEach((t) => t.disable()); + + while (contextMenuEl.lastChild) { + contextMenuEl.removeChild(contextMenuEl.lastChild); + } + options.populateContextMenu(contextMenuEl, tool.state); + + tool._element && tool._element.classList.add("using"); + tool.enabled = true; + enable(tool.state, opt); + }, + disable: (opt = null) => { + tool._element && tool._element.classList.remove("using"); + disable(tool.state, opt); + tool.enabled = false; + }, + }; + + // Initalize + options.init && options.init(tool.state); + + this.tools.push(tool); + + // Add tool to toolbar + tool._element = this._makeToolbarEntry(tool); + this._toolbar.appendChild(tool._element); + + return tool; + }, + + addSeparator() { + const separator = document.createElement("div"); + separator.classList.add("separator"); + this._toolbar.appendChild(separator); + }, +}; + +/** + * Dream and img2img tools + */ +const _reticle_draw = (evn, snapToGrid = true) => { + if (evn.target.id === "overlayCanvas") { + const bb = getBoundingBox( + evn.x, + evn.y, + basePixelCount * scaleFactor, + basePixelCount * scaleFactor, + snapToGrid && basePixelCount + ); + + // draw targeting square reticle thingy cursor + ovCtx.strokeStyle = "#FFF"; + ovCtx.strokeRect(bb.x, bb.y, bb.w, bb.h); //origin is middle of the frame + } +}; + +const tools = {}; + +/** + * Dream tool + */ +tools.dream = toolbar.registerTool( + "res/icons/image-plus.svg", + "Dream", + (state, opt) => { + // Draw new cursor immediately + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + _reticle_draw({...mouse.canvas.pos, target: {id: "overlayCanvas"}}); + + // Start Listeners + mouse.listen.canvas.onmousemove.on(state.mousemovecb); + mouse.listen.canvas.left.onclick.on(state.dreamcb); + mouse.listen.canvas.right.onclick.on(state.erasecb); + }, + (state, opt) => { + // Clear Listeners + mouse.listen.canvas.onmousemove.clear(state.mousemovecb); + mouse.listen.canvas.left.onclick.clear(state.dreamcb); + mouse.listen.canvas.right.onclick.clear(state.erasecb); + }, + { + init: (state) => { + state.snapToGrid = true; + state.mousemovecb = (evn) => _reticle_draw(evn, state.snapToGrid); + state.dreamcb = (evn) => { + dream_generate_callback(evn, state); + }; + state.erasecb = (evn) => dream_erase_callback(evn, state); + }, + populateContextMenu: (menu, state) => { + if (!state.ctxmenu) { + state.ctxmenu = {}; + // Snap To Grid Checkbox + const snapToGridCheckbox = document.createElement("input"); + snapToGridCheckbox.type = "checkbox"; + snapToGridCheckbox.checked = state.snapToGrid; + snapToGridCheckbox.onchange = () => + (state.snapToGrid = snapToGridCheckbox.checked); + state.ctxmenu.snapToGridCheckbox = snapToGridCheckbox; + + const snapToGridLabel = document.createElement("label"); + snapToGridLabel.appendChild(snapToGridCheckbox); + snapToGridLabel.appendChild(new Text("Snap to Grid")); + state.ctxmenu.snapToGridLabel = snapToGridLabel; + } + + menu.appendChild(state.ctxmenu.snapToGridLabel); + }, + shortcut: "D", + } +); + +/** + * Mask Editing tools + */ +toolbar.addSeparator(); + +/** + * Mask Brush tool + */ +tools.maskbrush = toolbar.registerTool( + "res/icons/paintbrush.svg", + "Mask Brush", + (state, opt) => { + // Draw new cursor immediately + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb({...mouse.canvas.pos, target: {id: "overlayCanvas"}}); + + // Start Listeners + mouse.listen.canvas.onmousemove.on(state.movecb); + mouse.listen.canvas.onwheel.on(state.wheelcb); + mouse.listen.canvas.left.onpaint.on(state.drawcb); + mouse.listen.canvas.right.onpaint.on(state.erasecb); + }, + (state, opt) => { + // Clear Listeners + mouse.listen.canvas.onmousemove.clear(state.movecb); + mouse.listen.canvas.onwheel.on(state.wheelcb); + mouse.listen.canvas.left.onpaint.clear(state.drawcb); + mouse.listen.canvas.right.onpaint.clear(state.erasecb); + }, + { + init: (state) => { + state.config = { + brushScrollSpeed: 1 / 4, + minBrushSize: 10, + maxBrushSize: 500, + }; + + state.brushSize = 64; + state.setBrushSize = (size) => { + state.brushSize = size; + state.ctxmenu.brushSizeRange.value = size; + state.ctxmenu.brushSizeText.value = size; + }; + + state.movecb = (evn) => { + if (evn.target.id === "overlayCanvas") { + // draw big translucent red blob cursor + 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 = "#FF6A6A50"; + ovCtx.fill(); + } + }; + + state.wheelcb = (evn) => { + if (evn.target.id === "overlayCanvas") { + state.setBrushSize( + Math.max( + state.config.minBrushSize, + Math.min( + state.config.maxBrushSize, + state.brushSize - + Math.floor(state.config.brushScrollSpeed * evn.delta) + ) + ) + ); + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb(evn); + } + }; + + state.drawcb = (evn) => mask_brush_draw_callback(evn, state); + state.erasecb = (evn) => mask_brush_erase_callback(evn, state); + }, + populateContextMenu: (menu, state) => { + if (!state.ctxmenu) { + state.ctxmenu = {}; + // Brush Size slider + const brushSizeRange = document.createElement("input"); + brushSizeRange.type = "range"; + brushSizeRange.value = state.brushSize; + brushSizeRange.max = state.config.maxBrushSize; + brushSizeRange.step = 8; + brushSizeRange.min = state.config.minBrushSize; + brushSizeRange.oninput = () => + (state.brushSize = parseInt(brushSizeRange.value)); + state.ctxmenu.brushSizeRange = brushSizeRange; + const brushSizeText = document.createElement("input"); + brushSizeText.type = "number"; + brushSizeText.value = state.brushSize; + brushSizeText.oninput = () => + (state.brushSize = parseInt(brushSizeText.value)); + state.ctxmenu.brushSizeText = brushSizeText; + + const brushSizeLabel = document.createElement("label"); + brushSizeLabel.appendChild(new Text("Brush Size")); + brushSizeLabel.appendChild(brushSizeText); + brushSizeLabel.appendChild(brushSizeRange); + state.ctxmenu.brushSizeLabel = brushSizeLabel; + } + + menu.appendChild(state.ctxmenu.brushSizeLabel); + }, + shortcut: "M", + } +); + +toolbar.tools[0].enable(); diff --git a/js/util.js b/js/util.js index f8b9f51..d16e67c 100644 --- a/js/util.js +++ b/js/util.js @@ -41,3 +41,41 @@ const guid = (size = 3) => { id += s4(); return id; }; + +/** + * Bounding box Calculation + */ +function snap(i, scaled = true, gridSize = 64) { + // very cheap test proof of concept but it works surprisingly well + var scaleOffset = 0; + if (scaled) { + if (scaleFactor % 2 != 0) { + // odd number, snaps to center of cell, oops + scaleOffset = gridSize / 2; + } + } + var snapOffset = (i % gridSize) - scaleOffset; + if (snapOffset == 0) { + return snapOffset; + } + return -snapOffset; +} + +function getBoundingBox(cx, cy, w, h, gridSnap = null) { + const offset = {x: 0, y: 0}; + const box = {x: 0, y: 0}; + + if (gridSnap) { + offset.x = snap(cx, true, gridSnap); + offset.y = snap(cy, true, gridSnap); + } + box.x = offset.x + cx; + box.y = offset.y + cy; + + return { + x: Math.floor(box.x - w / 2), + y: Math.floor(box.y - h / 2), + w, + h, + }; +} diff --git a/res/icons/image-plus.svg b/res/icons/image-plus.svg new file mode 100644 index 0000000..4a15bad --- /dev/null +++ b/res/icons/image-plus.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/res/icons/image.svg b/res/icons/image.svg new file mode 100644 index 0000000..a45c6a3 --- /dev/null +++ b/res/icons/image.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/res/icons/paintbrush.svg b/res/icons/paintbrush.svg new file mode 100644 index 0000000..7b33711 --- /dev/null +++ b/res/icons/paintbrush.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file