diff --git a/css/icons.css b/css/icons.css new file mode 100644 index 0000000..ee50b80 --- /dev/null +++ b/css/icons.css @@ -0,0 +1,39 @@ +.ui.icon > .icon-eye-off { + -webkit-mask-image: url("/res/icons/eye-off.svg"); + mask-image: url("/res/icons/eye-off.svg"); +} + +.ui.icon > .icon-eye { + -webkit-mask-image: url("/res/icons/eye.svg"); + mask-image: url("/res/icons/eye.svg"); +} + +.ui.icon > .icon-file-plus { + -webkit-mask-image: url("/res/icons/file-plus.svg"); + mask-image: url("/res/icons/file-plus.svg"); +} + +.ui.icon > .icon-file-x { + -webkit-mask-image: url("/res/icons/file-x.svg"); + mask-image: url("/res/icons/file-x.svg"); +} + +.ui.icon > .icon-chevron-down { + -webkit-mask-image: url("/res/icons/chevron-down.svg"); + mask-image: url("/res/icons/chevron-down.svg"); +} + +.ui.icon > .icon-chevron-up { + -webkit-mask-image: url("/res/icons/chevron-up.svg"); + mask-image: url("/res/icons/chevron-up.svg"); +} +.ui.icon > .icon-chevron-first { + -webkit-mask-image: url("/res/icons/chevron-first.svg"); + mask-image: url("/res/icons/chevron-first.svg"); +} + +.ui.icon > .icon-chevron-flat-down { + -webkit-mask-image: url("/res/icons/chevron-first.svg"); + mask-image: url("/res/icons/chevron-first.svg"); + transform: rotate(-90deg); +} diff --git a/css/index.css b/css/index.css index acfb655..64b7cc8 100644 --- a/css/index.css +++ b/css/index.css @@ -20,30 +20,6 @@ body { overflow: clip; } -.container { - position: relative; -} - -.backgroundCanvas { - background-color: #ccc; -} - -.mainHSplit { - display: grid; - grid-template-columns: 1fr; - grid-template-rows: repeat(2, 1fr); - grid-column-gap: 5px; - grid-row-gap: 5px; -} - -.uiWrapper { - display: grid; - grid-template-columns: 1fr 15fr; - grid-template-rows: 1fr; - grid-column-gap: 5px; - grid-row-gap: 5px; -} - .collapsible { background-color: rgb(0, 0, 0); color: rgb(255, 255, 255); @@ -88,28 +64,6 @@ body { cursor: auto; } -.canvasHolder { - position: relative; - width: 2560px; - height: 1440px; -} - -.mainCanvases { - position: absolute; - top: 0px; - left: 0px; - width: 2560px; - height: 1440px; -} - -.masks { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: 1fr; - grid-column-gap: 0px; - grid-row-gap: 0px; -} - /* Mask colors for mask inversion */ /* Filters are some magic acquired at https://codepen.io/sosuke/pen/Pjoqqp */ .mask-canvas { @@ -135,13 +89,6 @@ body { brightness(103%) contrast(108%); } -.strokeText { - -webkit-text-stroke: 1px #000; - font-size: 150%; - font-weight: 600; - color: #fff; -} - .wideSelect { width: 100%; text-overflow: ellipsis; diff --git a/css/layers.css b/css/layers.css index 8675b29..6d426c0 100644 --- a/css/layers.css +++ b/css/layers.css @@ -44,3 +44,22 @@ bottom: 0; right: 0; } + +#layer-overlay { + position: fixed; + + top: 0; + left: 0; + + width: 100%; + height: 100%; + + pointer-events: none; + + z-index: 15; +} + +#layer-render.pixelated canvas { + image-rendering: pixelated; + image-rendering: crisp-edges; +} diff --git a/css/ui/generic.css b/css/ui/generic.css index dd97f48..a7c2271 100644 --- a/css/ui/generic.css +++ b/css/ui/generic.css @@ -113,3 +113,58 @@ select > option:checked::after { mask-image: url("/res/icons/check.svg"); mask-size: contain; } +/*************/ +/* UI styles */ +/*************/ + +/* The separator */ +.ui.separator { + width: 80%; + margin: auto; + align-self: center; + border-top: 1px var(--c-hover) solid; +} + +/* Icon button */ +.ui.square { + aspect-ratio: 1; +} + +.ui.button { + cursor: pointer; + + padding: 0; + margin: 0; + border: 0; + color: var(--c-text); + background-color: var(--c-primary); + transition-duration: 50ms; +} + +.ui.button:hover { + background-color: var(--c-hover); +} + +.ui.button:active { + background-color: var(--c-hover); + filter: brightness(120%); +} + +.ui.button.icon { + display: flex; + align-items: stretch; +} + +.ui.button.icon > *:first-child { + flex: 1; + margin: 3px; + + -webkit-mask-position: center; + mask-position: center; + + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + background-color: var(--c-text); +} diff --git a/css/ui/layers.css b/css/ui/layers.css new file mode 100644 index 0000000..376af52 --- /dev/null +++ b/css/ui/layers.css @@ -0,0 +1,134 @@ +.layer-manager { + display: flex; + flex-direction: column; + align-items: stretch; + + border-radius: 5px; + overflow: hidden; + + background-color: var(--c-primary); +} + +#layer-list { + height: 200px; + + overflow-y: auto; + + background-color: var(--c-primary); + + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +#layer-list > *:first-child { + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +#layer-list .ui-layer { + display: flex; + align-items: center; + justify-content: space-between; + + height: 25px; + padding-left: 5px; + padding-right: 5px; + + cursor: pointer; + + color: var(--c-text); + + transition-duration: 50ms; +} + +#layer-list .ui-layer.active { + background-color: var(--c-active); +} +#layer-list .ui-layer.active:hover, +#layer-list .ui-layer:hover { + background-color: var(--c-hover); +} +#layer-list .ui-layer.active:active, +#layer-list .ui-layer:active { + background-color: var(--c-hover); + filter: brightness(120%); +} + +#layer-list .ui-layer > .title { + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + background-color: transparent; + + border: 0; + color: var(--c-text); +} + +#layer-list .ui-layer > .actions { + display: flex; + align-self: stretch; +} + +#layer-list .actions > button { + display: flex; + align-items: stretch; + + padding: 0; + + width: 25px; + aspect-ratio: 1; + + background-color: transparent; + border: 0; + cursor: pointer; +} + +#layer-list .ui-layer > .actions > *:hover > * { + margin: 2px; +} + +#layer-list .actions > button > *:first-child { + flex: 1; + margin: 3px; + + -webkit-mask-size: contain; + mask-size: contain; + background-color: var(--c-text); +} + +#layer-list .actions > .rename-btn > *:first-child { + -webkit-mask-image: url("/res/icons/edit.svg"); + mask-image: url("/res/icons/edit.svg"); +} + +#layer-list .actions > .delete-btn > *:first-child { + -webkit-mask-image: url("/res/icons/trash.svg"); + mask-image: url("/res/icons/trash.svg"); +} + +#layer-list .actions > .hide-btn > *:first-child { + -webkit-mask-image: url("/res/icons/eye.svg"); + mask-image: url("/res/icons/eye.svg"); +} +#layer-list .hidden .actions > .hide-btn > *:first-child { + -webkit-mask-image: url("/res/icons/eye-off.svg"); + mask-image: url("/res/icons/eye-off.svg"); +} + +.layer-manager > .separator { + width: calc(100% - 10px); +} + +.layer-manager > .layer-list-actions { + display: flex; + padding: 0; + + justify-content: stretch; +} + +.layer-manager > .layer-list-actions > * { + flex: 1; + height: 25px; +} diff --git a/css/ui/tool/dream.css b/css/ui/tool/dream.css new file mode 100644 index 0000000..784ed59 --- /dev/null +++ b/css/ui/tool/dream.css @@ -0,0 +1,3 @@ +.dream-interrupt-btn { + width: 100px; +} diff --git a/index.html b/index.html index 93fe739..14ac634 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ openOutpaint 🐠 + @@ -12,9 +13,11 @@ + + @@ -109,6 +112,12 @@ step="1" onchange="changeMaskBlur()" />
+ +
@@ -194,11 +203,66 @@
+ +
+
Layers
+ +
+
+ style="right: 270px; top: 10px">
@@ -209,6 +273,9 @@
+ + + @@ -225,6 +292,7 @@ + diff --git a/js/index.js b/js/index.js index da9d063..8144408 100644 --- a/js/index.js +++ b/js/index.js @@ -113,8 +113,8 @@ function startup() { drawBackground(); changeSampler(); changeMaskBlur(); + changeSmoothRendering(); changeSeed(); - changeOverMaskPx(); changeHiResFix(); } @@ -331,116 +331,28 @@ async function testHostConnection() { function newImage(evt) { clearPaintedMask(); - clearBackupMask(); - commands.runCommand("eraseImage", "Clear Canvas", { - x: 0, - y: 0, - w: imgCanvas.width, - h: imgCanvas.height, + uil.layers.forEach(({layer}) => { + commands.runCommand("eraseImage", "Clear Canvas", { + x: 0, + y: 0, + w: layer.canvas.width, + h: layer.canvas.height, + ctx: layer.ctx, + }); }); } -function prevImg(evt) { - if (imageIndex == 0) { - imageIndex = totalImagesReturned; - } - changeImg(false); -} - -function nextImg(evt) { - if (imageIndex == totalImagesReturned - 1) { - imageIndex = -1; - } - changeImg(true); -} - -function changeImg(forward) { - const img = new Image(); - tempCtx.clearRect(0, 0, tempCtx.width, tempCtx.height); - img.onload = function () { - tempCtx.drawImage(img, tmpImgXYWH.x, tmpImgXYWH.y); //imgCtx for actual image, tmp for... holding? - }; - var tmpIndex = document.getElementById("currentImgIndex"); - if (forward) { - imageIndex++; - } else { - imageIndex--; - } - tmpIndex.innerText = imageIndex + 1; - // load the image data after defining the closure - img.src = "data:image/png;base64," + returnedImages[imageIndex]; //TODO need way to dream batches and select from results -} - -function removeChoiceButtons(evt) { - const element = document.getElementById("veryTempDiv"); - element.remove(); - tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); -} - -function backupAndClearMask(x, y, w, h) { - var clearArea = maskPaintCtx.createImageData(w, h); - backupMaskChunk = maskPaintCtx.getImageData(x, y, w, h); - backupMaskX = x; - backupMaskY = y; - var clearD = clearArea.data; - for (i = 0; i < clearD.length; i += 4) { - clearD[i] = 0; - clearD[i + 1] = 0; - clearD[i + 2] = 0; - clearD[i + 3] = 0; - } - maskPaintCtx.putImageData(clearArea, x, y); -} - -function restoreBackupMask() { - // reapply mask if exists - if (backupMaskChunk != null && backupMaskX != null && backupMaskY != null) { - // backup mask data exists - var iData = new ImageData( - backupMaskChunk.data, - backupMaskChunk.height, - backupMaskChunk.width - ); - maskPaintCtx.putImageData(iData, backupMaskX, backupMaskY); - } -} - -function clearBackupMask() { - // clear backupmask - backupMaskChunk = null; - backupMaskX = null; - backupMaskY = null; -} - -function clearImgMask() { - imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height); -} - function clearPaintedMask() { maskPaintCtx.clearRect(0, 0, maskPaintCanvas.width, maskPaintCanvas.height); } -function placeImage() { - const img = new Image(); - img.onload = function () { - commands.runCommand("drawImage", "Image Dream", { - x: tmpImgXYWH.x, - y: tmpImgXYWH.y, - image: img, - }); - tmpImgXYWH = {}; - returnedImages = null; - }; - // load the image data after defining the closure - img.src = "data:image/png;base64," + returnedImages[imageIndex]; -} +function march(bb, options = {}) { + defaultOpt(options, { + style: "#FFFF", + width: "2px", + filter: null, + }); -function sleep(ms) { - // what was this even for, anyway? - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function march(bb) { const expanded = {...bb}; expanded.x--; expanded.y--; @@ -455,7 +367,7 @@ function march(bb) { let offset = 0; const interval = setInterval(() => { - drawMarchingAnts(layer.ctx, bb, offset++); + drawMarchingAnts(layer.ctx, bb, offset++, options); offset %= 12; }, 20); @@ -465,13 +377,18 @@ function march(bb) { }; } -function drawMarchingAnts(ctx, bb, offset) { +function drawMarchingAnts(ctx, bb, offset, options) { + ctx.save(); + ctx.clearRect(0, 0, bb.w + 2, bb.h + 2); - ctx.strokeStyle = "#FFFFFFFF"; //"#55000077"; - ctx.strokeWidth = "2px"; + ctx.strokeStyle = options.style; + ctx.strokeWidth = options.width; + ctx.filter = options.filter; ctx.setLineDash([4, 2]); ctx.lineDashOffset = -offset; ctx.strokeRect(1, 1, bb.w, bb.h); + + ctx.restore(); } function changeSampler() { @@ -563,10 +480,6 @@ makeSlider( makeSlider("Steps", document.getElementById("steps"), "steps", 1, 70, 5, 30, 1); -function changeSnapMode() { - snapToGrid = document.getElementById("cbxSnap").checked; -} - function changeMaskBlur() { stableDiffusionData.mask_blur = parseInt( document.getElementById("maskBlur").value @@ -579,20 +492,22 @@ function changeSeed() { localStorage.setItem("seed", stableDiffusionData.seed); } -function changeOverMaskPx() { - // overMaskPx = document.getElementById("overMaskPx").value; - // localStorage.setItem("overmask_px", overMaskPx); -} - function changeHiResFix() { stableDiffusionData.enable_hr = Boolean( document.getElementById("cbxHRFix").checked ); localStorage.setItem("enable_hr", stableDiffusionData.enable_hr); } +function changeSmoothRendering() { + const layers = document.getElementById("layer-render"); + if (document.getElementById("cbxSmooth").checked) { + layers.classList.remove("pixelated"); + } else { + layers.classList.add("pixelated"); + } +} -function isCanvasBlank(x, y, w, h, specifiedCanvas) { - var canvas = document.getElementById(specifiedCanvas.id); +function isCanvasBlank(x, y, w, h, canvas) { return !canvas .getContext("2d") .getImageData(x, y, w, h) @@ -871,7 +786,14 @@ async function upscaleAndDownload() { // get cropped canvas, send it to upscaler, download result var upscale_factor = 2; // TODO: make this a user input 1.x - 4.0 or something var upscaler = document.getElementById("upscalers").value; - var croppedCanvas = cropCanvas(imgCanvas); + var croppedCanvas = cropCanvas( + uil.getVisible({ + x: 0, + y: 0, + w: imageCollection.size.w, + h: imageCollection.size.h, + }) + ); if (croppedCanvas != null) { var upscaler = document.getElementById("upscalers").value; var url = @@ -936,15 +858,6 @@ function loadSettings() { ? false : localStorage.getItem("enable_hr") ); - var _enable_erase = Boolean( - localStorage.getItem("enable_erase") == (null || "false") - ? false - : localStorage.getItem("enable_erase") - ); - var _overmask_px = - localStorage.getItem("overmask_px") == null - ? 0 - : localStorage.getItem("overmask_px"); // set the values into the UI document.getElementById("prompt").value = String(_prompt); @@ -955,7 +868,6 @@ function loadSettings() { document.getElementById("maskBlur").value = Number(_mask_blur); document.getElementById("seed").value = Number(_seed); document.getElementById("cbxHRFix").checked = Boolean(_enable_hr); - // document.getElementById("overMaskPx").value = Number(_overmask_px); } imageCollection.element.addEventListener( diff --git a/js/initalize/layers.populate.js b/js/initalize/layers.populate.js index 1f1a39b..9263dfe 100644 --- a/js/initalize/layers.populate.js +++ b/js/initalize/layers.populate.js @@ -12,9 +12,11 @@ const bgLayer = imageCollection.registerLayer("bg", { }); const imgLayer = imageCollection.registerLayer("image", { name: "Image", + ctxOptions: {desynchronized: true}, }); const maskPaintLayer = imageCollection.registerLayer("mask", { name: "Mask Paint", + ctxOptions: {desynchronized: true}, }); const ovLayer = imageCollection.registerLayer("overlay", { name: "Overlay", @@ -37,25 +39,12 @@ const ovCtx = ovLayer.ctx; const debugCanvas = debugLayer.canvas; // where mouse cursor renders const debugCtx = debugLayer.ctx; -/** - * Function that returns a canvas with full visible information of a certain bounding box. - * - * For now, only the img is used. - * - * @param {BoundingBox} bb The bouding box to get visible data from - * @returns {HTMLCanvasElement} The canvas element containing visible image data - */ -const getVisible = (bb) => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - canvas.width = bb.w; - canvas.height = bb.h; - ctx.drawImage(bgLayer.canvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); - ctx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); - - return canvas; -}; +/* WIP: Most cursors shouldn't need a zoomable canvas */ +/** @type {HTMLCanvasElement} */ +const uiCanvas = document.getElementById("layer-overlay"); // where mouse cursor renders +uiCanvas.width = uiCanvas.clientWidth; +uiCanvas.height = uiCanvas.clientHeight; +const uiCtx = uiCanvas.getContext("2d", {desynchronized: true}); debugLayer.hide(); // Hidden by default @@ -131,6 +120,15 @@ const viewport = { get h() { return (window.innerHeight * 1) / this.zoom; }, + viewToCanvas(x, y) { + return {x, y}; + }, + canvasToView(x, y) { + return { + x: window.innerWidth * ((x - this.cx) / this.w) + window.innerWidth / 2, + y: window.innerHeight * ((y - this.cy) / this.h) + window.innerHeight / 2, + }; + }, /** * Apply transformation * @@ -155,6 +153,7 @@ viewport.transform(imageCollection.element); mouse.listen.window.onwheel.on((evn) => { if (evn.evn.ctrlKey) { evn.evn.preventDefault(); + const pcx = viewport.cx; const pcy = viewport.cy; if (evn.delta < 0) { @@ -168,6 +167,8 @@ mouse.listen.window.onwheel.on((evn) => { viewport.transform(imageCollection.element); + toolbar.currentTool.redraw(); + if (debug) { debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); debugCtx.fillStyle = "#F0F"; @@ -210,4 +211,6 @@ mouse.listen.window.btn.middle.onpaintend.on((evn) => { window.addEventListener("resize", () => { viewport.transform(imageCollection.element); + uiCanvas.width = uiCanvas.clientWidth; + uiCanvas.height = uiCanvas.clientHeight; }); diff --git a/js/lib/commands.js b/js/lib/commands.js index 91bcaae..f665d1e 100644 --- a/js/lib/commands.js +++ b/js/lib/commands.js @@ -4,9 +4,6 @@ const _commands_events = new Observer(); -/** CommandNonExistentError */ -class CommandNonExistentError extends Error {} - /** Global Commands Object */ const commands = makeReadOnly( { @@ -32,7 +29,14 @@ const commands = makeReadOnly( */ async undo(n = 1) { for (var i = 0; i < n && this.current > -1; i++) { - await this._history[this._current--].undo(); + try { + await this._history[this._current--].undo(); + } catch (e) { + console.warn("[commands] Failed to undo command"); + console.warn(e); + this._current++; + break; + } } }, /** @@ -42,7 +46,14 @@ const commands = makeReadOnly( */ async redo(n = 1) { for (var i = 0; i < n && this.current + 1 < this._history.length; i++) { - await this._history[++this._current].redo(); + try { + await this._history[++this._current].redo(); + } catch { + console.warn("[commands] Failed to redo command"); + console.warn(e); + this._current--; + break; + } } }, @@ -67,7 +78,7 @@ const commands = makeReadOnly( * @returns {Command} */ createCommand(name, run, undo, redo = run) { - const command = async function runWrapper(title, options) { + const command = async function runWrapper(title, options, extra) { // Create copy of options and state object const copy = {}; Object.assign(copy, options); @@ -93,11 +104,11 @@ const commands = makeReadOnly( return; } - const undoWrapper = () => { + const undoWrapper = async () => { console.debug( `[commands] Undoing '${title}'[${name}], currently ${this._current}` ); - undo(title, state); + await undo(title, state); _commands_events.emit({ id: entry.id, name, @@ -106,11 +117,11 @@ const commands = makeReadOnly( current: this._current, }); }; - const redoWrapper = () => { + const redoWrapper = async () => { console.debug( `[commands] Redoing '${title}'[${name}], currently ${this._current}` ); - redo(title, copy, state); + await redo(title, copy, state); _commands_events.emit({ id: entry.id, name, @@ -120,6 +131,11 @@ const commands = makeReadOnly( }); }; + entry.undo = undoWrapper; + entry.redo = redoWrapper; + + if (!extra.recordHistory) return entry; + // Add to history if (commands._history.length > commands._current + 1) { commands._history.forEach((entry, index) => { @@ -139,9 +155,6 @@ const commands = makeReadOnly( commands._history.push(entry); commands._current++; - entry.undo = undoWrapper; - entry.redo = redoWrapper; - _commands_events.emit({ id: entry.id, name, @@ -163,13 +176,16 @@ const commands = makeReadOnly( * @param {string} name The name of the command to run * @param {string} title The display name of the command on the history panel view * @param {any} options The options to be sent to the command to be run + * @return {Promise<{undo: () => void, redo: () => void}>} The command's return value */ - runCommand(name, title, options = null) { + async runCommand(name, title, options = null, extra = {}) { + defaultOpt(extra, { + recordHistory: true, + }); if (!this._types[name]) - throw new CommandNonExistentError( - `[commands] Command '${name}' does not exist` - ); - this._types[name](title, options); + throw new ReferenceError(`[commands] Command '${name}' does not exist`); + + return this._types[name](title, options, extra); }, }, "commands", @@ -192,7 +208,7 @@ commands.createCommand( // Check if we have state if (!state.context) { - const context = options.ctx || imgCtx; + const context = options.ctx || uil.ctx; state.context = context; // Saving what was in the canvas before the command @@ -252,16 +268,10 @@ commands.createCommand( // Check if we have state if (!state.context) { - const context = options.ctx || imgCtx; + const context = options.ctx || uil.ctx; 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, @@ -272,7 +282,19 @@ commands.createCommand( const cutout = document.createElement("canvas"); cutout.width = state.box.w; cutout.height = state.box.h; - cutout.getContext("2d").putImageData(imgData, 0, 0); + cutout + .getContext("2d") + .drawImage( + context.canvas, + options.x, + options.y, + options.w, + options.h, + 0, + 0, + options.w, + options.h + ); state.original = new Image(); state.original.src = cutout.toDataURL(); } diff --git a/js/lib/layers.js b/js/lib/layers.js index cede377..7c32372 100644 --- a/js/lib/layers.js +++ b/js/lib/layers.js @@ -77,8 +77,6 @@ const layers = { size, resolution: options.resolution, - active: null, - /** * Registers a new layer * @@ -87,7 +85,9 @@ const layers = { * @param {string} options.name * @param {?BoundingBox} options.bb * @param {{w: number, h: number}} options.resolution + * @param {?string} options.group * @param {object} options.after + * @param {object} options.ctxOptions * @returns */ registerLayer: (key = null, options = {}) => { @@ -101,11 +101,17 @@ const layers = { // Bounding box for layer bb: {x: 0, y: 0, w: collection.size.w, h: collection.size.h}, - // Bounding box for layer + // Resolution for layer resolution: null, + // Group for the layer ("group/subgroup/subsubgroup") + group: null, + // If set, will insert the layer after the given one after: null, + + // Context creation options + ctxOptions: {}, }); // Calculate resolution @@ -135,7 +141,7 @@ const layers = { options.after.canvas.after(canvas); } - const ctx = canvas.getContext("2d"); + const ctx = canvas.getContext("2d", options.ctxOptions); // Path used for logging purposes const _layerlogpath = key @@ -168,6 +174,24 @@ const layers = { canvas, ctx, + /** + * Moves this layer to another level (after given layer) + * + * @param {Layer} layer Will move layer to after this one + */ + moveAfter(layer) { + layer.canvas.after(this.canvas); + }, + + /** + * Moves this layer to another level (before given layer) + * + * @param {Layer} layer Will move layer to before this one + */ + moveBefore(layer) { + layer.canvas.before(this.canvas); + }, + /** * Moves this layer to another location * @@ -204,14 +228,8 @@ const layers = { unhide() { this.canvas.style.display = "block"; }, - - // Activates this layer - activate() { - collection.active = this; - }, }, - _layerlogpath, - ["active"] + _layerlogpath ); // Add to indexers @@ -260,8 +278,7 @@ const layers = { else console.debug(`[layers] Anonymous layer '${lobj.id}' deleted`); }, }, - _logpath, - ["active"] + _logpath ); layers._collections.push(collection); @@ -271,11 +288,6 @@ const layers = { `[layers] Collection '${options.name}' at ${_logpath} registered` ); - // We must create a layer to select - collection - .registerLayer(options.initLayer.key, options.initLayer.options) - .activate(); - return collection; }, }; diff --git a/js/lib/toolbar.js b/js/lib/toolbar.js index beda095..670dfd6 100644 --- a/js/lib/toolbar.js +++ b/js/lib/toolbar.js @@ -8,6 +8,10 @@ const toolbar = { _toolbar_lock_indicator: document.getElementById("toolbar-lock-indicator"), tools: [], + _current_tool: null, + get currentTool() { + return this._current_tool; + }, lock() { toolbar._locked = true; @@ -88,6 +92,12 @@ const toolbar = { _element: null, state: {}, options, + /** + * If the tool has a redraw() function in its state, then run it + */ + redraw: () => { + tool.state.redraw && tool.state.redraw(); + }, enable: (opt = null) => { if (toolbar._locked) return; @@ -100,12 +110,15 @@ const toolbar = { tool._element && tool._element.classList.add("using"); tool.enabled = true; + + this._current_tool = tool; enable(tool.state, opt); }, disable: (opt = null) => { tool._element && tool._element.classList.remove("using"); - disable(tool.state, opt); + this._current_tool = null; tool.enabled = false; + disable(tool.state, opt); }, }; diff --git a/js/lib/util.js b/js/lib/util.js index 5fa5f6d..bdd0a63 100644 --- a/js/lib/util.js +++ b/js/lib/util.js @@ -250,14 +250,19 @@ function cropCanvas(sourceCanvas, options = {}) { * * @param {Object} options - Optional Information * @param {boolean} [options.cropToContent] - If we wish to crop to content first (default: true) - * @param {HTMLCanvasElement} [options.canvas] - The source canvas (default: imgCanvas) + * @param {HTMLCanvasElement} [options.canvas] - The source canvas (default: visible) * @param {string} [options.filename] - The filename to save as (default: '[ISO date] [Hours] [Minutes] [Seconds] openOutpaint image.png').\ * If null, opens image in new tab. */ function downloadCanvas(options = {}) { defaultOpt(options, { cropToContent: true, - canvas: imgCanvas, + canvas: uil.getVisible({ + x: 0, + y: 0, + w: imageCollection.size.w, + h: imageCollection.size.h, + }), filename: new Date() .toISOString() diff --git a/js/ui/floating/layers.js b/js/ui/floating/layers.js new file mode 100644 index 0000000..e5c3ab6 --- /dev/null +++ b/js/ui/floating/layers.js @@ -0,0 +1,537 @@ +/** + * The layering UI window + */ + +const uil = { + _ui_layer_list: document.getElementById("layer-list"), + layers: [], + _active: null, + set active(v) { + Array.from(this._ui_layer_list.children).forEach((child) => { + child.classList.remove("active"); + }); + + v.entry.classList.add("active"); + + this._active = v; + }, + get active() { + return this._active; + }, + + get layer() { + return this.active && this.active.layer; + }, + + get canvas() { + return this.layer && this.active.layer.canvas; + }, + + get ctx() { + return this.layer && this.active.layer.ctx; + }, + + get w() { + return imageCollection.size.w; + }, + get h() { + return imageCollection.size.h; + }, + + /** + * Synchronizes layer array to DOM + */ + _syncLayers() { + const layersEl = document.getElementById("layer-list"); + + const copy = this.layers.map((i) => i); + copy.reverse(); + + copy.forEach((uiLayer, index) => { + // If we have the correct layer here, then do nothing + if ( + layersEl.children[index] && + layersEl.children[index].id === `ui-layer-${uiLayer.id}` + ) + return; + + // If the layer we are processing does not exist, then create it and add before current element + if (!uiLayer.entry) { + uiLayer.entry = document.createElement("div"); + uiLayer.entry.id = `ui-layer-${uiLayer.id}`; + uiLayer.entry.classList.add("ui-layer"); + uiLayer.entry.addEventListener("click", () => { + this.active = uiLayer; + }); + + // Title Element + const titleEl = document.createElement("input"); + titleEl.classList.add("title"); + titleEl.value = uiLayer.name; + titleEl.style.pointerEvents = "none"; + + const deselect = () => { + titleEl.style.pointerEvents = "none"; + titleEl.setSelectionRange(0, 0); + }; + + titleEl.addEventListener("blur", deselect); + uiLayer.entry.appendChild(titleEl); + + uiLayer.entry.addEventListener("change", () => { + const name = titleEl.value.trim(); + titleEl.value = name; + uiLayer.entry.title = name; + + uiLayer.name = name; + + this._syncLayers(); + + titleEl.blur(); + }); + uiLayer.entry.addEventListener("dblclick", () => { + titleEl.style.pointerEvents = "auto"; + titleEl.focus(); + titleEl.select(); + }); + + // Add action buttons + const actionArray = document.createElement("div"); + actionArray.classList.add("actions"); + + if (uiLayer.deletable) { + const deleteButton = document.createElement("button"); + deleteButton.addEventListener( + "click", + (evn) => { + commands.runCommand("deleteLayer", "Deleted Layer", { + layer: uiLayer, + }); + }, + {passive: false} + ); + + deleteButton.addEventListener( + "dblclick", + (evn) => { + evn.stopPropagation(); + }, + {passive: false} + ); + deleteButton.title = "Delete Layer"; + deleteButton.appendChild(document.createElement("div")); + deleteButton.classList.add("delete-btn"); + + actionArray.appendChild(deleteButton); + } + + const hideButton = document.createElement("button"); + hideButton.addEventListener( + "click", + (evn) => { + evn.stopPropagation(); + uiLayer.hidden = !uiLayer.hidden; + if (uiLayer.hidden) { + uiLayer.entry.classList.add("hidden"); + } else uiLayer.entry.classList.remove("hidden"); + }, + {passive: false} + ); + hideButton.addEventListener( + "dblclick", + (evn) => { + evn.stopPropagation(); + }, + {passive: false} + ); + hideButton.title = "Hide/Unhide Layer"; + hideButton.appendChild(document.createElement("div")); + hideButton.classList.add("hide-btn"); + + actionArray.appendChild(hideButton); + uiLayer.entry.appendChild(actionArray); + + if (layersEl.children[index]) + layersEl.children[index].before(uiLayer.entry); + else layersEl.appendChild(uiLayer.entry); + } else if (!layersEl.querySelector(`#ui-layer-${uiLayer.id}`)) { + // If layer exists but is not on the DOM, add it back + if (index === 0) layersEl.children[0].before(uiLayer.entry); + else layersEl.children[index - 1].after(uiLayer.entry); + } else { + // If the layer already exists, just move it here + layersEl.children[index].before(uiLayer.entry); + } + }); + + // Deletes layer if not in array + for (var i = 0; i < layersEl.children.length; i++) { + if (!copy.find((l) => layersEl.children[i].id === `ui-layer-${l.id}`)) { + layersEl.children[i].remove(); + } + } + + // Synchronizes with the layer lib + this.layers.forEach((uiLayer, index) => { + if (index === 0) uiLayer.layer.moveAfter(bgLayer); + else uiLayer.layer.moveAfter(copy[index - 1].layer); + }); + }, + + /** + * Adds a user-manageable layer for image editing. + * + * Should not be called directly. Use the command instead. + * + * @param {string} group The group the layer belongs to. [does nothing for now] + * @param {string} name The name of the new layer. + * @returns + */ + _addLayer(group, name) { + const layer = imageCollection.registerLayer(null, { + name, + after: + (this.layers.length > 0 && this.layers[this.layers.length - 1].layer) || + bgLayer, + }); + + const uiLayer = { + id: layer.id, + group, + name, + _hidden: false, + set hidden(v) { + if (v) { + this._hidden = true; + this.layer.hide(v); + } else { + this._hidden = false; + this.layer.unhide(v); + } + }, + get hidden() { + return this._hidden; + }, + entry: null, + layer, + }; + this.layers.push(uiLayer); + + this._syncLayers(); + + this.active = uiLayer; + + return uiLayer; + }, + + /** + * Moves a layer to a specified position. + * + * Should not be called directly. Use the command instead. + * + * @param {UserLayer} layer Layer to move + * @param {number} position Position to move the layer to + */ + _moveLayerTo(layer, position) { + if (position < 0 || position >= this.layers.length) + throw new RangeError("Position out of bounds"); + + const index = this.layers.indexOf(layer); + if (index !== -1) { + if (this.layers.length < 2) return; // Do nothing if moving a layer doesn't make sense + + this.layers.splice(index, 1); + this.layers.splice(position, 0, layer); + + this._syncLayers(); + + return; + } + throw new ReferenceError("Layer could not be found"); + }, + /** + * Moves a layer up a single position. + * + * Should not be called directly. Use the command instead. + * + * @param {UserLayer} [layer=uil.active] Layer to move + */ + _moveLayerUp(layer = uil.active) { + const index = this.layers.indexOf(layer); + if (index === -1) throw new ReferenceError("Layer could not be found"); + try { + this._moveLayerTo(layer, index + 1); + } catch (e) {} + }, + /** + * Moves a layer down a single position. + * + * Should not be called directly. Use the command instead. + * + * @param {UserLayer} [layer=uil.active] Layer to move + */ + _moveLayerDown(layer = uil.active) { + const index = this.layers.indexOf(layer); + if (index === -1) throw new ReferenceError("Layer could not be found"); + try { + this._moveLayerTo(layer, index - 1); + } catch (e) {} + }, + /** + * Function that returns a canvas with full visible information of a certain bounding box. + * + * For now, only the img is used. + * + * @param {BoundingBox} bb The bouding box to get visible data from + * @param {object} [options] Options + * @param {boolean} [options.includeBg=false] Whether to include the background + * @returns {HTMLCanvasElement} The canvas element containing visible image data + */ + getVisible(bb, options = {}) { + defaultOpt(options, { + includeBg: false, + }); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + canvas.width = bb.w; + canvas.height = bb.h; + if (options.includeBg) + ctx.drawImage(bgLayer.canvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + this.layers.forEach((layer) => { + if (!layer.hidden) + ctx.drawImage( + layer.layer.canvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + bb.w, + bb.h + ); + }); + + return canvas; + }, +}; + +/** + * Command for creating a new layer + */ +commands.createCommand( + "addLayer", + (title, opt, state) => { + const options = Object.assign({}, opt) || {}; + defaultOpt(options, { + group: null, + name: "New Layer", + deletable: true, + }); + + if (!state.layer) { + const {group, name} = options; + + const layer = imageCollection.registerLayer(null, { + name, + after: + (uil.layers.length > 0 && uil.layers[uil.layers.length - 1].layer) || + bgLayer, + }); + + state.layer = { + id: layer.id, + group, + name, + deletable: options.deletable, + _hidden: false, + set hidden(v) { + if (v) { + this._hidden = true; + this.layer.hide(v); + } else { + this._hidden = false; + this.layer.unhide(v); + } + }, + get hidden() { + return this._hidden; + }, + entry: null, + layer, + }; + } + uil.layers.push(state.layer); + + uil._syncLayers(); + + uil.active = state.layer; + }, + (title, state) => { + const index = uil.layers.findIndex((v) => v === state.layer); + + if (index === -1) throw new ReferenceError("Layer could not be found"); + + if (uil.active === state.layer) + uil.active = uil.layers[index + 1] || uil.layers[index - 1]; + uil.layers.splice(index, 1); + uil._syncLayers(); + } +); + +/** + * Command for moving a layer to a position + */ +commands.createCommand( + "moveLayer", + (title, opt, state) => { + const options = opt || {}; + defaultOpt(options, { + layer: null, + to: null, + delta: null, + }); + + if (!state.layer) { + if (options.to === null && options.delta === null) + throw new Error( + "[layers.moveLayer] Options must contain one of {to?, delta?}" + ); + + const layer = options.layer || uil.active; + + const index = uil.layers.indexOf(layer); + if (index === -1) throw new ReferenceError("Layer could not be found"); + + let position = options.to; + + if (position === null) position = index + options.delta; + + state.layer = layer; + state.oldposition = index; + state.position = position; + } + + uil._moveLayerTo(state.layer, state.position); + }, + (title, state) => { + uil._moveLayerTo(state.layer, state.oldposition); + } +); + +/** + * Command for deleting a layer + */ +commands.createCommand( + "deleteLayer", + (title, opt, state) => { + const options = opt || {}; + defaultOpt(options, { + layer: null, + }); + + if (!state.layer) { + const layer = options.layer || uil.active; + + if (!layer.deletable) + throw new TypeError("[layer.deleteLayer] Layer is not deletable"); + + const index = uil.layers.indexOf(layer); + if (index === -1) + throw new ReferenceError( + "[layer.deleteLayer] Layer could not be found" + ); + + state.layer = layer; + state.position = index; + } + + uil.layers.splice(state.position, 1); + uil.active = uil.layers[state.position - 1] || uil.layers[state.position]; + + uil._syncLayers(); + + state.layer.layer.hide(); + }, + (title, state) => { + uil.layers.splice(state.position, 0, state.layer); + uil.active = state.layer; + + uil._syncLayers(); + + state.layer.layer.unhide(); + } +); + +/** + * Command for merging a layer into the layer below it + */ +commands.createCommand( + "mergeLayer", + async (title, opt, state) => { + const options = opt || {}; + defaultOpt(options, { + layerS: null, + layerD: null, + }); + + const layerS = options.layer || uil.active; + + if (!layerS.deletable) + throw new TypeError( + "[layer.mergeLayer] Layer is a root layer and cannot be merged" + ); + + const index = uil.layers.indexOf(layerS); + if (index === -1) + throw new ReferenceError("[layer.mergeLayer] Layer could not be found"); + + if (index === 0 && !options.layerD) + throw new ReferenceError( + "[layer.mergeLayer] No layer below source layer exists" + ); + + // Use layer under source layer to merge into if not given + const layerD = options.layerD || uil.layers[index - 1]; + + state.layerS = layerS; + state.layerD = layerD; + + // REFERENCE: This is a great reference for metacommands (commands that use other commands) + // These commands should NOT record history as we are already executing a command + state.drawCommand = await commands.runCommand( + "drawImage", + "Merge Layer Draw", + { + image: state.layerS.layer.canvas, + x: 0, + y: 0, + ctx: state.layerD.layer.ctx, + }, + {recordHistory: false} + ); + state.delCommand = await commands.runCommand( + "deleteLayer", + "Merge Layer Delete", + {layer: state.layerS}, + {recordHistory: false} + ); + }, + (title, state) => { + state.drawCommand.undo(); + state.delCommand.undo(); + }, + (title, options, state) => { + state.drawCommand.redo(); + state.delCommand.redo(); + } +); + +commands.runCommand( + "addLayer", + "Initial Layer Creation", + {name: "Default Image Layer", deletable: false}, + {recordHistory: false} +); diff --git a/js/ui/tool/colorbrush.js b/js/ui/tool/colorbrush.js index 27a7366..2cbdfd7 100644 --- a/js/ui/tool/colorbrush.js +++ b/js/ui/tool/colorbrush.js @@ -3,7 +3,12 @@ const _color_brush_draw_callback = (evn, state) => { ctx.strokeStyle = state.color; - ctx.filter = "blur(" + state.brushBlur + "px)"; + ctx.filter = + "blur(" + + state.brushBlur + + "px) opacity(" + + state.brushOpacity * 100 + + "%)"; ctx.lineWidth = state.brushSize; ctx.beginPath(); ctx.moveTo( @@ -13,6 +18,7 @@ const _color_brush_draw_callback = (evn, state) => { ctx.lineTo(evn.x, evn.y); ctx.lineJoin = ctx.lineCap = "round"; ctx.stroke(); + ctx.filter = null; }; const _color_brush_erase_callback = (evn, state, ctx) => { @@ -35,8 +41,14 @@ const colorBrushTool = () => "Color Brush", (state, opt) => { // Draw new cursor immediately - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - state.movecb({...mouse.coords.world.pos}); + uiCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb({ + ...mouse.coords.world.pos, + evn: { + clientX: mouse.coords.window.pos.x, + clientY: mouse.coords.window.pos.y, + }, + }); // Layer for eyedropper magnifiying glass state.glassLayer = imageCollection.registerLayer(null, { @@ -44,21 +56,24 @@ const colorBrushTool = () => resolution: {w: 7, h: 7}, after: maskPaintLayer, }); - state.glassLayer.canvas.style.display = "none"; + state.glassLayer.hide(); state.glassLayer.canvas.style.imageRendering = "pixelated"; state.glassLayer.canvas.style.borderRadius = "50%"; state.drawLayer = imageCollection.registerLayer(null, { after: imgLayer, + ctxOptions: {willReadFrequently: true}, }); state.eraseLayer = imageCollection.registerLayer(null, { after: imgLayer, + ctxOptions: {willReadFrequently: true}, }); state.eraseLayer.canvas.style.display = "none"; + state.eraseLayer.hide(); state.eraseBackup = imageCollection.registerLayer(null, { after: imgLayer, }); - state.eraseBackup.canvas.style.display = "none"; + state.eraseBackup.hide(); // Start Listeners mouse.listen.world.onmousemove.on(state.movecb); @@ -105,6 +120,8 @@ const colorBrushTool = () => // Cancel any eyedropping state.drawing = false; state.disableDropper(); + + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); }, { init: (state) => { @@ -119,6 +136,7 @@ const colorBrushTool = () => state.color = "#FFFFFF"; state.brushSize = 32; state.brushBlur = 0; + state.brushOpacity = 1; state.affectMask = true; state.setBrushSize = (size) => { state.brushSize = size; @@ -131,13 +149,13 @@ const colorBrushTool = () => state.enableDropper = () => { state.eyedropper = true; state.movecb(lastMouseMoveEvn); - state.glassLayer.canvas.style.display = "block"; + state.glassLayer.unhide(); }; state.disableDropper = () => { state.eyedropper = false; state.movecb(lastMouseMoveEvn); - state.glassLayer.canvas.style.display = "none"; + state.glassLayer.hide(); }; let lastMouseMoveEvn = {x: 0, y: 0}; @@ -145,26 +163,43 @@ const colorBrushTool = () => state.movecb = (evn) => { lastMouseMoveEvn = evn; - // draw drawing cursor - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + const vcp = {x: evn.evn.clientX, y: evn.evn.clientY}; + // draw drawing cursor + uiCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + + uiCtx.beginPath(); + uiCtx.arc( + vcp.x, + vcp.y, + (state.eyedropper ? 50 : state.brushSize / 2) * viewport.zoom, + 0, + 2 * Math.PI, + true + ); + uiCtx.strokeStyle = "black"; + uiCtx.stroke(); + + // Draw eyedropper cursor and magnifiying glass if (state.eyedropper) { const bb = getBoundingBox(evn.x, evn.y, 7, 7, false); - const canvas = getVisible(bb); + const canvas = uil.getVisible(bb, {includeBg: true}); state.glassLayer.ctx.clearRect(0, 0, 7, 7); state.glassLayer.ctx.drawImage(canvas, 0, 0); state.glassLayer.moveTo(evn.x - 50, evn.y - 50); - - ovCtx.beginPath(); - ovCtx.arc(evn.x, evn.y, 50, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 7x on a line??? - ovCtx.strokeStyle = "black"; - ovCtx.stroke(); } else { - 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 7x on a line??? - ovCtx.fillStyle = state.color + "50"; - ovCtx.fill(); + uiCtx.beginPath(); + uiCtx.arc( + vcp.x, + vcp.y, + (state.brushSize / 2) * viewport.zoom, + 0, + 2 * Math.PI, + true + ); + uiCtx.fillStyle = state.color + "50"; + uiCtx.fill(); } }; @@ -174,7 +209,7 @@ const colorBrushTool = () => state.brushSize - Math.floor(state.config.brushScrollSpeed * evn.delta) ); - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + uiCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); state.movecb(evn); } }; @@ -211,13 +246,20 @@ const colorBrushTool = () => state.leftclickcb = (evn) => { if (evn.target === imageCollection.inputElement && state.eyedropper) { const bb = getBoundingBox(evn.x, evn.y, 1, 1, false); - const visibleCanvas = getVisible(bb); + const visibleCanvas = uil.getVisible(bb); const dat = visibleCanvas .getContext("2d") .getImageData(0, 0, 1, 1).data; state.setColor( "#" + ((dat[0] << 16) | (dat[1] << 8) | dat[2]).toString(16) ); + state.disableDropper(); + } + }; + + state.rightclickcb = (evn) => { + if (evn.target === imageCollection.inputElement && state.eyedropper) { + state.disableDropper(); } }; @@ -255,29 +297,35 @@ const colorBrushTool = () => }; state.erasestartcb = (evn) => { + if (state.eyedropper) return; + state.erasing = true; 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); + bkpctx.drawImage(uil.canvas, 0, 0); - imgCtx.globalCompositeOperation = "destination-out"; - _color_brush_erase_callback(evn, state, imgCtx); - imgCtx.globalCompositeOperation = "source-over"; + uil.ctx.globalCompositeOperation = "destination-out"; + _color_brush_erase_callback(evn, state, uil.ctx); + uil.ctx.globalCompositeOperation = "source-over"; _color_brush_erase_callback(evn, state, state.eraseLayer.ctx); }; state.erasecb = (evn) => { + if (state.eyedropper || !state.erasing) return; if (state.affectMask) _mask_brush_erase_callback(evn, state); - imgCtx.globalCompositeOperation = "destination-out"; - _color_brush_erase_callback(evn, state, imgCtx); - imgCtx.globalCompositeOperation = "source-over"; + uil.ctx.globalCompositeOperation = "destination-out"; + _color_brush_erase_callback(evn, state, uil.ctx); + uil.ctx.globalCompositeOperation = "source-over"; _color_brush_erase_callback(evn, state, state.eraseLayer.ctx); }; state.eraseendcb = (evn) => { + if (!state.erasing) return; + state.erasing = false; + const canvas = state.eraseLayer.canvas; const ctx = state.eraseLayer.ctx; @@ -286,8 +334,9 @@ const colorBrushTool = () => const cropped = cropCanvas(canvas, {border: 10}); const bb = cropped.bb; - imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height); - imgCtx.drawImage(bkpcanvas, 0, 0); + uil.ctx.filter = null; + uil.ctx.clearRect(0, 0, uil.canvas.width, uil.canvas.height); + uil.ctx.drawImage(bkpcanvas, 0, 0); commands.runCommand("eraseImage", "Color Brush Erase", { mask: cropped.canvas, @@ -325,7 +374,21 @@ const colorBrushTool = () => state.ctxmenu.brushSizeSlider = brushSizeSlider.slider; state.setBrushSize = brushSizeSlider.setValue; - // Brush size slider + // Brush opacity slider + const brushOpacitySlider = _toolbar_input.slider( + state, + "brushOpacity", + "Brush Opacity", + { + min: 0, + max: 1, + step: 0.05, + textStep: 0.001, + } + ); + state.ctxmenu.brushOpacitySlider = brushOpacitySlider.slider; + + // Brush blur slider const brushBlurSlider = _toolbar_input.slider( state, "brushBlur", @@ -364,7 +427,8 @@ const colorBrushTool = () => "eyedropper" ); brushColorEyeDropper.addEventListener("click", () => { - state.enableDropper(); + if (state.eyedropper) state.disableDropper(); + else state.enableDropper(); }); brushColorPickerWrapper.appendChild(brushColorPicker); @@ -375,6 +439,7 @@ const colorBrushTool = () => menu.appendChild(state.ctxmenu.affectMaskCheckbox); menu.appendChild(state.ctxmenu.brushSizeSlider); + menu.appendChild(state.ctxmenu.brushOpacitySlider); menu.appendChild(state.ctxmenu.brushBlurSlider); menu.appendChild(state.ctxmenu.brushColorPicker); }, diff --git a/js/ui/tool/dream.js b/js/ui/tool/dream.js index fd37ac9..f98a47a 100644 --- a/js/ui/tool/dream.js +++ b/js/ui/tool/dream.js @@ -1,12 +1,14 @@ let blockNewImages = false; +let generating = false; /** * Starts progress monitoring bar * * @param {BoundingBox} bb Bouding Box to draw progress to + * @param {(data: object) => void} [oncheck] Callback function for when a progress check returns * @returns {() => void} */ -const _monitorProgress = (bb) => { +const _monitorProgress = (bb, oncheck = null) => { const minDelay = 1000; const apiURL = `${host}${url}progress?skip_current_image=true`; @@ -33,6 +35,8 @@ const _monitorProgress = (bb) => { /** @type {StableDiffusionProgressResponse} */ const data = await response.json(); + oncheck && oncheck(data); + // Draw Progress Bar layer.ctx.fillStyle = "#5F5"; layer.ctx.fillRect(1, 1, bb.w * data.progress, 10); @@ -81,6 +85,7 @@ const _dream = async (endpoint, request) => { /** @type {StableDiffusionResponse} */ let data = null; try { + generating = true; const response = await fetch(apiURL, { method: "POST", headers: { @@ -92,6 +97,7 @@ const _dream = async (endpoint, request) => { data = await response.json(); } finally { + generating = false; } return data.images; @@ -103,9 +109,15 @@ const _dream = async (endpoint, request) => { * @param {"txt2img" | "img2img"} endpoint Endpoint to send the request to * @param {StableDiffusionRequest} request Stable diffusion request * @param {BoundingBox} bb Generated image placement location + * @param {number} [drawEvery=0.2 / request.n_iter] Percentage delta to draw progress at (by default 20% of each iteration) * @returns {Promise} */ -const _generate = async (endpoint, request, bb) => { +const _generate = async ( + endpoint, + request, + bb, + drawEvery = 0.2 / request.n_iter +) => { const requestCopy = {...request}; // Images to select through @@ -120,26 +132,47 @@ const _generate = async (endpoint, request, bb) => { after: maskPaintLayer, }); - const redraw = () => { + const makeElement = (type, x, y) => { + const el = document.createElement(type); + el.style.position = "absolute"; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + + // We can use the input element to add interactible html elements in the world + imageCollection.inputElement.appendChild(el); + + return el; + }; + + const redraw = (url = images[at]) => { + if (!url) return; + const image = new Image(); - image.src = "data:image/png;base64," + images[at]; + image.src = "data:image/png;base64," + url; image.addEventListener("load", () => { layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height); - if (images[at]) - layer.ctx.drawImage( - image, - 0, - 0, - image.width, - image.height, - bb.x, - bb.y, - bb.w, - bb.h - ); + layer.ctx.drawImage( + image, + 0, + 0, + image.width, + image.height, + bb.x, + bb.y, + bb.w, + bb.h + ); }); }; + // Add Interrupt Button + const interruptButton = makeElement("button", bb.x + bb.w - 100, bb.y + bb.h); + interruptButton.classList.add("dream-interrupt-btn"); + interruptButton.textContent = "Interrupt"; + interruptButton.addEventListener("click", () => { + fetch(`${host}${url}interrupt`, {method: "POST"}); + interruptButton.disabled = true; + }); const stopMarchingAnts = march(bb); // First Dream Run @@ -148,8 +181,28 @@ const _generate = async (endpoint, request, bb) => { let stopProgress = null; try { - stopProgress = _monitorProgress(bb); + let stopDrawingStatus = false; + let lastProgress = 0; + let nextCP = drawEvery; + stopProgress = _monitorProgress(bb, (data) => { + if (stopDrawingStatus) return; + + if (lastProgress < nextCP && data.progress >= nextCP) { + nextCP += drawEvery; + fetch(`${host}${url}progress?skip_current_image=false`).then( + async (response) => { + if (stopDrawingStatus) return; + const imagedata = await response.json(); + redraw(imagedata.current_image); + } + ); + } + lastProgress = data.progress; + }); + + imageCollection.inputElement.appendChild(interruptButton); images.push(...(await _dream(endpoint, requestCopy))); + stopDrawingStatus = true; } catch (e) { alert( `Error generating images. Please try again or see consolde for more details` @@ -158,6 +211,7 @@ const _generate = async (endpoint, request, bb) => { console.warn(e); } finally { stopProgress(); + imageCollection.inputElement.removeChild(interruptButton); } // Image navigation @@ -196,6 +250,8 @@ const _generate = async (endpoint, request, bb) => { const makeMore = async () => { try { stopProgress = _monitorProgress(bb); + interruptButton.disabled = false; + imageCollection.inputElement.appendChild(interruptButton); images.push(...(await _dream(endpoint, requestCopy))); imageindextxt.textContent = `${at + 1}/${images.length}`; } catch (e) { @@ -206,6 +262,7 @@ const _generate = async (endpoint, request, bb) => { console.warn(e); } finally { stopProgress(); + imageCollection.inputElement.removeChild(interruptButton); } }; @@ -265,18 +322,6 @@ const _generate = async (endpoint, request, bb) => { keyboard.listen.onkeyclick.clear(onarrow); }; - const makeElement = (type, x, y) => { - const el = document.createElement(type); - el.style.position = "absolute"; - el.style.left = `${x}px`; - el.style.top = `${y}px`; - - // We can use the input element to add interactible html elements in the world - imageCollection.inputElement.appendChild(el); - - return el; - }; - redraw(); imageSelectMenu = makeElement("div", bb.x, bb.y + bb.h); @@ -366,8 +411,11 @@ const dream_generate_callback = async (evn, state) => { // Don't allow another image until is finished blockNewImages = true; + // Get visible pixels + const visibleCanvas = uil.getVisible(bb); + // Use txt2img if canvas is blank - if (isCanvasBlank(bb.x, bb.y, bb.w, bb.h, imgCanvas)) { + if (isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) { // Dream _generate("txt2img", request, bb); } else { @@ -384,9 +432,9 @@ const dream_generate_callback = async (evn, state) => { // Get init image auxCtx.fillRect(0, 0, request.width, request.height); auxCtx.drawImage( - imgCanvas, - bb.x, - bb.y, + visibleCanvas, + 0, + 0, bb.w, bb.h, 0, @@ -417,9 +465,9 @@ const dream_generate_callback = async (evn, state) => { auxCtx.globalCompositeOperation = "destination-in"; auxCtx.drawImage( - imgCanvas, - bb.x, - bb.y, + visibleCanvas, + 0, + 0, bb.w, bb.h, 0, @@ -430,9 +478,9 @@ const dream_generate_callback = async (evn, state) => { } else { auxCtx.globalCompositeOperation = "destination-in"; auxCtx.drawImage( - imgCanvas, - bb.x, - bb.y, + visibleCanvas, + 0, + 0, bb.w, bb.h, 0, @@ -535,8 +583,11 @@ const dream_img2img_callback = (evn, state) => { state.snapToGrid && basePixelCount ); + // Get visible pixels + const visibleCanvas = uil.getVisible(bb); + // Do nothing if no image exists - if (isCanvasBlank(bb.x, bb.y, bb.w, bb.h, imgCanvas)) return; + if (isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) return; // Build request to the API const request = {}; @@ -565,9 +616,9 @@ const dream_img2img_callback = (evn, state) => { // Get init image auxCtx.fillRect(0, 0, request.width, request.height); auxCtx.drawImage( - imgCanvas, - bb.x, - bb.y, + visibleCanvas, + 0, + 0, bb.w, bb.h, 0, @@ -601,14 +652,48 @@ const dream_img2img_callback = (evn, state) => { if (state.keepBorderSize > 0) { auxCtx.globalCompositeOperation = "source-over"; auxCtx.fillStyle = "#000F"; + if (state.gradient) { + const lg = auxCtx.createLinearGradient(0, 0, state.keepBorderSize, 0); + lg.addColorStop(0, "#000F"); + lg.addColorStop(1, "#0000"); + auxCtx.fillStyle = lg; + } auxCtx.fillRect(0, 0, state.keepBorderSize, request.height); + if (state.gradient) { + const tg = auxCtx.createLinearGradient(0, 0, 0, state.keepBorderSize); + tg.addColorStop(0, "#000F"); + tg.addColorStop(1, "#0000"); + auxCtx.fillStyle = tg; + } auxCtx.fillRect(0, 0, request.width, state.keepBorderSize); + if (state.gradient) { + const rg = auxCtx.createLinearGradient( + request.width, + 0, + request.width - state.keepBorderSize, + 0 + ); + rg.addColorStop(0, "#000F"); + rg.addColorStop(1, "#0000"); + auxCtx.fillStyle = rg; + } auxCtx.fillRect( request.width - state.keepBorderSize, 0, state.keepBorderSize, request.height ); + if (state.gradient) { + const bg = auxCtx.createLinearGradient( + 0, + request.height, + 0, + request.height - state.keepBorderSize + ); + bg.addColorStop(0, "#000F"); + bg.addColorStop(1, "#0000"); + auxCtx.fillStyle = bg; + } auxCtx.fillRect( 0, request.height - state.keepBorderSize, @@ -628,7 +713,14 @@ const dream_img2img_callback = (evn, state) => { /** * Dream and img2img tools */ -const _reticle_draw = (evn, state) => { +const _reticle_draw = (evn, state, tool, style = {}) => { + defaultOpt(style, { + sizeTextStyle: "#FFF5", + toolTextStyle: "#FFF5", + reticleWidth: 1, + reticleStyle: "#FFF", + }); + const bb = getBoundingBox( evn.x, evn.y, @@ -636,14 +728,70 @@ const _reticle_draw = (evn, state) => { state.cursorSize, state.snapToGrid && basePixelCount ); + const bbvp = { + ...viewport.canvasToView(bb.x, bb.y), + w: viewport.zoom * bb.w, + h: viewport.zoom * bb.h, + }; + + uiCtx.save(); // draw targeting square reticle thingy cursor - ovCtx.lineWidth = 1; - ovCtx.strokeStyle = "#FFF"; - ovCtx.strokeRect(bb.x, bb.y, bb.w, bb.h); //origin is middle of the frame + uiCtx.lineWidth = style.reticleWidth; + uiCtx.strokeStyle = style.reticleStyle; + uiCtx.strokeRect(bbvp.x, bbvp.y, bbvp.w, bbvp.h); //origin is middle of the frame + + uiCtx.font = `bold 20px Open Sans`; + + // Draw Tool Name + { + const xshrink = Math.min(1, (bbvp.w - 20) / uiCtx.measureText(tool).width); + + uiCtx.font = `bold ${20 * xshrink}px Open Sans`; + + uiCtx.textAlign = "left"; + uiCtx.fillStyle = style.toolTextStyle; + uiCtx.fillText(tool, bbvp.x + 10, bbvp.y + 10 + 20 * xshrink); + } + + // Draw width and height + { + uiCtx.textAlign = "center"; + uiCtx.fillStyle = style.sizeTextStyle; + uiCtx.translate(bbvp.x + bbvp.w / 2, bbvp.y + bbvp.h / 2); + const xshrink = Math.min( + 1, + (bbvp.w - 30) / uiCtx.measureText(`${state.cursorSize}px`).width + ); + const yshrink = Math.min( + 1, + (bbvp.h - 30) / uiCtx.measureText(`${state.cursorSize}px`).width + ); + uiCtx.font = `bold ${20 * xshrink}px Open Sans`; + uiCtx.fillText( + `${state.cursorSize}px`, + 0, + bbvp.h / 2 - 10 * xshrink, + state.cursorSize + ); + uiCtx.rotate(-Math.PI / 2); + uiCtx.font = `bold ${20 * yshrink}px Open Sans`; + uiCtx.fillText( + `${state.cursorSize}px`, + 0, + bbvp.h / 2 - 10 * yshrink, + state.cursorSize + ); + + uiCtx.restore(); + } return () => { - ovCtx.clearRect(bb.x - 10, bb.y - 10, bb.w + 20, bb.h + 20); + uiCtx.save(); + + uiCtx.clearRect(bbvp.x - 10, bbvp.y - 10, bbvp.w + 20, bbvp.h + 20); + + uiCtx.restore(); }; }; @@ -670,10 +818,11 @@ const dreamTool = () => "Dream", (state, opt) => { // Draw new cursor immediately - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - state.mousemovecb({ + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); + state.lastMouseMove = { ...mouse.coords.world.pos, - }); + }; + state.redraw(); // Start Listeners mouse.listen.world.onmousemove.on(state.mousemovecb); @@ -693,6 +842,8 @@ const dreamTool = () => // Hide Mask setMask("none"); + + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); }, { init: (state) => { @@ -707,12 +858,30 @@ const dreamTool = () => state.overMaskPx = 0; state.erasePrevReticle = () => - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); + + state.lastMouseMove = { + ...mouse.coords.world.pos, + }; state.mousemovecb = (evn) => { + state.lastMouseMove = evn; state.erasePrevReticle(); - state.erasePrevReticle = _reticle_draw(evn, state); + const style = + state.cursorSize > stableDiffusionData.width + ? "#FBB5" + : state.cursorSize < stableDiffusionData.width + ? "#BFB5" + : "#FFF5"; + state.erasePrevReticle = _reticle_draw(evn, state, "Dream", { + sizeTextStyle: style, + }); }; + + state.redraw = () => { + state.mousemovecb(state.lastMouseMove); + }; + state.wheelcb = (evn) => { _dream_onwheel(evn, state); }; @@ -789,10 +958,10 @@ const img2imgTool = () => "Img2Img", (state, opt) => { // Draw new cursor immediately - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - state.mousemovecb({ + state.lastMouseMove = { ...mouse.coords.world.pos, - }); + }; + state.redraw(); // Start Listeners mouse.listen.world.onmousemove.on(state.mousemovecb); @@ -812,6 +981,7 @@ const img2imgTool = () => // Hide mask setMask("none"); + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); }, { init: (state) => { @@ -827,13 +997,28 @@ const img2imgTool = () => state.denoisingStrength = 0.7; state.keepBorderSize = 64; + state.gradient = true; state.erasePrevReticle = () => - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); + state.lastMouseMove = { + ...mouse.coords.world.pos, + }; state.mousemovecb = (evn) => { + state.lastMouseMove = evn; state.erasePrevReticle(); - state.erasePrevReticle = _reticle_draw(evn, state); + + const style = + state.cursorSize > stableDiffusionData.width + ? "#FBB5" + : state.cursorSize < stableDiffusionData.width + ? "#BFB5" + : "#FFF5"; + state.erasePrevReticle = _reticle_draw(evn, state, "Img2Img", { + sizeTextStyle: style, + }); + const bb = getBoundingBox( evn.x, evn.y, @@ -848,6 +1033,12 @@ const img2imgTool = () => height: stableDiffusionData.height, }; + const bbvp = { + ...viewport.canvasToView(bb.x, bb.y), + w: viewport.zoom * bb.w, + h: viewport.zoom * bb.h, + }; + // For displaying border mask const auxCanvas = document.createElement("canvas"); auxCanvas.width = request.width; @@ -856,33 +1047,82 @@ const img2imgTool = () => if (state.keepBorderSize > 0) { auxCtx.fillStyle = "#6A6AFF30"; + if (state.gradient) { + const lg = auxCtx.createLinearGradient( + 0, + 0, + state.keepBorderSize, + 0 + ); + lg.addColorStop(0, "#6A6AFF30"); + lg.addColorStop(1, "#0000"); + auxCtx.fillStyle = lg; + } auxCtx.fillRect(0, 0, state.keepBorderSize, request.height); + if (state.gradient) { + const tg = auxCtx.createLinearGradient( + 0, + 0, + 0, + state.keepBorderSize + ); + tg.addColorStop(0, "#6A6AFF30"); + tg.addColorStop(1, "#6A6AFF00"); + auxCtx.fillStyle = tg; + } auxCtx.fillRect(0, 0, request.width, state.keepBorderSize); + if (state.gradient) { + const rg = auxCtx.createLinearGradient( + request.width, + 0, + request.width - state.keepBorderSize, + 0 + ); + rg.addColorStop(0, "#6A6AFF30"); + rg.addColorStop(1, "#6A6AFF00"); + auxCtx.fillStyle = rg; + } auxCtx.fillRect( request.width - state.keepBorderSize, 0, state.keepBorderSize, request.height ); + if (state.gradient) { + const bg = auxCtx.createLinearGradient( + 0, + request.height, + 0, + request.height - state.keepBorderSize + ); + bg.addColorStop(0, "#6A6AFF30"); + bg.addColorStop(1, "#6A6AFF00"); + auxCtx.fillStyle = bg; + } auxCtx.fillRect( 0, request.height - state.keepBorderSize, request.width, state.keepBorderSize ); - ovCtx.drawImage( + uiCtx.drawImage( auxCanvas, 0, 0, request.width, request.height, - bb.x, - bb.y, - bb.w, - bb.h + bbvp.x, + bbvp.y, + bbvp.w, + bbvp.h ); } }; + + state.redraw = () => { + state.mousemovecb(state.lastMouseMove); + }; + state.wheelcb = (evn) => { _dream_onwheel(evn, state); }; @@ -948,6 +1188,13 @@ const img2imgTool = () => } ).slider; + // Border Mask Gradient Checkbox + state.ctxmenu.borderMaskGradientCheckbox = _toolbar_input.checkbox( + state, + "gradient", + "Border Mask Gradient" + ).label; + // Border Mask Size Slider state.ctxmenu.borderMaskSlider = _toolbar_input.slider( state, @@ -970,8 +1217,14 @@ const img2imgTool = () => menu.appendChild(state.ctxmenu.fullResolutionLabel); menu.appendChild(document.createElement("br")); menu.appendChild(state.ctxmenu.denoisingStrengthSlider); + menu.appendChild(state.ctxmenu.borderMaskGradientCheckbox); menu.appendChild(state.ctxmenu.borderMaskSlider); }, shortcut: "I", } ); + +window.onbeforeunload = async () => { + // Stop current generation on page close + if (generating) await fetch(`${host}${url}interrupt`, {method: "POST"}); +}; diff --git a/js/ui/tool/interrogate.js b/js/ui/tool/interrogate.js index 6a45c81..aa77635 100644 --- a/js/ui/tool/interrogate.js +++ b/js/ui/tool/interrogate.js @@ -22,6 +22,8 @@ const interrogateTool = () => // Hide Mask setMask("none"); + + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); }, { init: (state) => { @@ -40,11 +42,16 @@ const interrogateTool = () => state.mousemovecb = (evn) => { state.erasePrevReticle(); - state.erasePrevReticle = _reticle_draw(evn, state); + state.erasePrevReticle = _reticle_draw(evn, state, "Interrogate", { + toolTextStyle: "#AFA5", + sizeTextStyle: "#AFA5", + reticleStyle: "#AFAF", + }); }; state.wheelcb = (evn) => { _interrogate_onwheel(evn, state); }; + state.interrogatecb = (evn) => { interrogate_callback(evn, state); }; @@ -98,7 +105,7 @@ const _interrogate_onwheel = (evn, state) => { } }; -const interrogate_callback = (evn, state) => { +const interrogate_callback = async (evn, state) => { const bb = getBoundingBox( evn.x, evn.y, @@ -107,7 +114,9 @@ const interrogate_callback = (evn, state) => { state.snapToGrid && basePixelCount ); // Do nothing if no image exists - if (isCanvasBlank(bb.x, bb.y, bb.w, bb.h, imgCanvas)) return; + const sectionCanvas = uil.getVisible({x: bb.x, y: bb.y, w: bb.w, h: bb.h}); + + if (isCanvasBlank(0, 0, bb.w, bb.h, sectionCanvas)) return; // Build request to the API const request = {}; @@ -122,16 +131,25 @@ const interrogate_callback = (evn, state) => { // 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); + auxCtx.drawImage(sectionCanvas, 0, 0); request.image = auxCanvas.toDataURL(); request.model = "clip"; //TODO maybe make a selectable option once A1111 supports the new openclip thingy? - const interrogation = _interrogate(request).then(function (result) { - if (confirm(result + "\n\nDo you want to replace your prompt with this?")) { - document.getElementById("prompt").value = result; + const stopMarching = march(bb, {style: "#AFAF"}); + try { + const result = await _interrogate(request); + const text = prompt( + result + + "\n\nDo you want to replace your prompt with this? You can change it down below:", + result + ); + if (text) { + document.getElementById("prompt").value = text; tools.dream.enable(); } - }); + } finally { + stopMarching(); + } }; /** diff --git a/js/ui/tool/maskbrush.js b/js/ui/tool/maskbrush.js index 6e6e8ce..d3993e4 100644 --- a/js/ui/tool/maskbrush.js +++ b/js/ui/tool/maskbrush.js @@ -17,15 +17,17 @@ const setMask = (state) => { canvas.classList.remove("display", "hold", "clear"); break; default: - console.debug(`Invalid mask type: ${state}`); + console.debug(`[maskbrush.setMask] Invalid mask type: ${state}`); break; } }; -const _mask_brush_draw_callback = (evn, state) => { +const _mask_brush_draw_callback = (evn, state, opacity = 100) => { maskPaintCtx.globalCompositeOperation = "source-over"; maskPaintCtx.strokeStyle = "black"; + maskPaintCtx.filter = + "blur(" + state.brushBlur + "px) opacity(" + opacity + "%)"; maskPaintCtx.lineWidth = state.brushSize; maskPaintCtx.beginPath(); maskPaintCtx.moveTo( @@ -35,12 +37,16 @@ const _mask_brush_draw_callback = (evn, state) => { maskPaintCtx.lineTo(evn.x, evn.y); maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round"; maskPaintCtx.stroke(); + maskPaintCtx.filter = null; }; -const _mask_brush_erase_callback = (evn, state) => { +const _mask_brush_erase_callback = (evn, state, opacity = 100) => { maskPaintCtx.globalCompositeOperation = "destination-out"; maskPaintCtx.strokeStyle = "black"; + maskPaintCtx.filter = "blur(" + state.brushBlur + "px)"; + maskPaintCtx.filter = + "blur(" + state.brushBlur + "px) opacity(" + opacity + "%)"; maskPaintCtx.lineWidth = state.brushSize; maskPaintCtx.beginPath(); maskPaintCtx.moveTo( @@ -50,34 +56,7 @@ const _mask_brush_erase_callback = (evn, state) => { maskPaintCtx.lineTo(evn.x, evn.y); maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round"; maskPaintCtx.stroke(); -}; - -const _paint_mb_cursor = (state) => { - const v = state.brushSize; - state.cursorLayer.resize(v + 20, v + 20); - - const ctx = state.cursorLayer.ctx; - - ctx.clearRect(0, 0, v + 20, v + 20); - ctx.beginPath(); - ctx.arc( - (v + 20) / 2, - (v + 20) / 2, - state.brushSize / 2, - 0, - 2 * Math.PI, - true - ); - ctx.fillStyle = "#FFFFFF50"; - - ctx.fill(); - - if (state.preview) { - ctx.strokeStyle = "#000F"; - ctx.setLineDash([4, 2]); - ctx.stroke(); - ctx.setLineDash([]); - } + maskPaintCtx.filter = null; }; const maskBrushTool = () => @@ -85,17 +64,9 @@ const maskBrushTool = () => "res/icons/paintbrush.svg", "Mask Brush", (state, opt) => { - // New layer for the cursor - state.cursorLayer = imageCollection.registerLayer(null, { - after: maskPaintLayer, - bb: {x: 0, y: 0, w: state.brushSize + 20, h: state.brushSize + 20}, - }); - - _paint_mb_cursor(state); - // Draw new cursor immediately - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - state.movecb({...mouse.coords.world.pos}); + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); + state.redraw(); // Start Listeners mouse.listen.world.onmousemove.on(state.movecb); @@ -109,10 +80,6 @@ const maskBrushTool = () => setMask("neutral"); }, (state, opt) => { - // Don't want to keep hogging resources - imageCollection.deleteLayer(state.cursorLayer); - state.cursorLayer = null; - // Clear Listeners mouse.listen.world.onmousemove.clear(state.movecb); mouse.listen.world.onwheel.clear(state.wheelcb); @@ -126,6 +93,8 @@ const maskBrushTool = () => state.ctxmenu.previewMaskButton.classList.remove("active"); maskPaintCanvas.classList.remove("opaque"); state.preview = false; + + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); }, { init: (state) => { @@ -133,9 +102,13 @@ const maskBrushTool = () => brushScrollSpeed: 1 / 4, minBrushSize: 10, maxBrushSize: 500, + minBlur: 0, + maxBlur: 30, }; state.brushSize = 64; + state.brushBlur = 0; + state.brushOpacity = 1; state.setBrushSize = (size) => { state.brushSize = size; state.ctxmenu.brushSizeRange.value = size; @@ -145,21 +118,41 @@ const maskBrushTool = () => state.preview = false; state.clearPrevCursor = () => - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); + + state.redraw = () => { + state.movecb({ + ...mouse.coords.world.pos, + evn: { + clientX: mouse.coords.window.pos.x, + clientY: mouse.coords.window.pos.y, + }, + }); + }; state.movecb = (evn) => { - state.cursorLayer.moveTo( - evn.x - state.brushSize / 2 - 10, - evn.y - state.brushSize / 2 - 10 - ); + const vcp = {x: evn.evn.clientX, y: evn.evn.clientY}; + const scp = state.brushSize * viewport.zoom; - state.clearPrevCursor = () => - ovCtx.clearRect( - evn.x - state.brushSize / 2 - 10, - evn.y - state.brushSize / 2 - 10, - evn.x + state.brushSize / 2 + 10, - evn.y + state.brushSize / 2 + 10 + state.clearPrevCursor(); + state; + clearPrevCursor = () => + uiCtx.clearRect( + vcp.x - scp / 2 - 10, + vcp.y - scp / 2 - 10, + vcp.x + scp / 2 + 10, + vcp.y + scp / 2 + 10 ); + + uiCtx.beginPath(); + uiCtx.arc(vcp.x, vcp.y, scp / 2, 0, 2 * Math.PI, true); + uiCtx.strokeStyle = "black"; + uiCtx.stroke(); + + uiCtx.beginPath(); + uiCtx.arc(vcp.x, vcp.y, scp / 2, 0, 2 * Math.PI, true); + uiCtx.fillStyle = "#FFFFFF50"; + uiCtx.fill(); }; state.wheelcb = (evn) => { @@ -168,16 +161,19 @@ const maskBrushTool = () => state.brushSize - Math.floor(state.config.brushScrollSpeed * evn.delta) ); - state.movecb(evn); } }; - state.drawcb = (evn) => _mask_brush_draw_callback(evn, state); - state.erasecb = (evn) => _mask_brush_erase_callback(evn, state); + state.drawcb = (evn) => + _mask_brush_draw_callback(evn, state, state.brushOpacity * 100); + state.erasecb = (evn) => + _mask_brush_erase_callback(evn, state, state.brushOpacity * 100); }, populateContextMenu: (menu, state) => { if (!state.ctxmenu) { state.ctxmenu = {}; + + // Brush size slider const brushSizeSlider = _toolbar_input.slider( state, "brushSize", @@ -189,13 +185,41 @@ const maskBrushTool = () => textStep: 1, cb: (v) => { if (!state.cursorLayer) return; - _paint_mb_cursor(state); + + state.redraw(); }, } ); state.ctxmenu.brushSizeSlider = brushSizeSlider.slider; state.setBrushSize = brushSizeSlider.setValue; + // Brush opacity slider + const brushOpacitySlider = _toolbar_input.slider( + state, + "brushOpacity", + "Brush Opacity", + { + min: 0, + max: 1, + step: 0.05, + textStep: 0.001, + } + ); + state.ctxmenu.brushOpacitySlider = brushOpacitySlider.slider; + + // Brush blur slider + const brushBlurSlider = _toolbar_input.slider( + state, + "brushBlur", + "Brush Blur", + { + min: state.config.minBlur, + max: state.config.maxBlur, + step: 1, + } + ); + state.ctxmenu.brushBlurSlider = brushBlurSlider.slider; + // Some mask-related action buttons const actionArray = document.createElement("div"); actionArray.classList.add("button-array"); @@ -221,11 +245,12 @@ const maskBrushTool = () => if (previewMaskButton.classList.contains("active")) { maskPaintCanvas.classList.remove("opaque"); state.preview = false; - _paint_mb_cursor(state); + + state.redraw(); } else { maskPaintCanvas.classList.add("opaque"); state.preview = true; - _paint_mb_cursor(state); + state.redraw(); } previewMaskButton.classList.toggle("active"); }; @@ -239,6 +264,8 @@ const maskBrushTool = () => } menu.appendChild(state.ctxmenu.brushSizeSlider); + menu.appendChild(state.ctxmenu.brushOpacitySlider); + menu.appendChild(state.ctxmenu.brushBlurSlider); menu.appendChild(state.ctxmenu.actionArray); }, shortcut: "M", diff --git a/js/ui/tool/select.js b/js/ui/tool/select.js index cec7c45..1ad41fd 100644 --- a/js/ui/tool/select.js +++ b/js/ui/tool/select.js @@ -46,6 +46,9 @@ const selectTransformTool = () => state.reset(); // Resets cursor + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + + // Clears overlay imageCollection.inputElement.style.cursor = "auto"; }, { @@ -76,7 +79,7 @@ const selectTransformTool = () => state.lastMouseTarget = null; state.lastMouseMove = null; - const redraw = () => { + state.redraw = () => { ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); state.movecb(state.lastMouseMove); }; @@ -84,7 +87,7 @@ const selectTransformTool = () => // Clears selection and make things right state.reset = () => { if (state.selected) - imgCtx.drawImage( + uil.ctx.drawImage( state.original.image, state.original.x, state.original.y @@ -93,7 +96,7 @@ const selectTransformTool = () => if (state.dragging) state.dragging = null; else state.selected = null; - redraw(); + state.redraw(); }; // Selection bounding box object. Has some witchery to deal with handles. @@ -188,6 +191,7 @@ const selectTransformTool = () => // Mouse move handler. As always, also renders cursor state.movecb = (evn) => { ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); imageCollection.inputElement.style.cursor = "auto"; state.lastMouseTarget = evn.target; state.lastMouseMove = evn; @@ -198,6 +202,10 @@ const selectTransformTool = () => y += snap(evn.y, 0, 64); } + const vpc = viewport.canvasToView(x, y); + + uiCtx.save(); + // Update scale if (state.scaling) { state.scaling.scaleTo(x, y, state.keepAspectRatio); @@ -212,17 +220,23 @@ const selectTransformTool = () => // Draw dragging box if (state.dragging) { - ovCtx.setLineDash([2, 2]); - ovCtx.lineWidth = 1; - ovCtx.strokeStyle = "#FFF"; + uiCtx.setLineDash([2, 2]); + uiCtx.lineWidth = 1; + uiCtx.strokeStyle = "#FFF"; const ix = state.dragging.ix; const iy = state.dragging.iy; const bb = selectionBB(ix, iy, x, y); - ovCtx.strokeRect(bb.x, bb.y, bb.w, bb.h); - ovCtx.setLineDash([]); + const bbvp = { + ...viewport.canvasToView(bb.x, bb.y), + w: viewport.zoom * bb.w, + h: viewport.zoom * bb.h, + }; + + uiCtx.strokeRect(bbvp.x, bbvp.y, bbvp.w, bbvp.h); + uiCtx.setLineDash([]); } if (state.selected) { @@ -236,6 +250,12 @@ const selectTransformTool = () => h: state.selected.h, }; + const bbvp = { + ...viewport.canvasToView(bb.x, bb.y), + w: viewport.zoom * bb.w, + h: viewport.zoom * bb.h, + }; + // Draw Image ovCtx.drawImage( state.selected.image, @@ -250,34 +270,40 @@ const selectTransformTool = () => ); // Draw selection box - ovCtx.setLineDash([4, 2]); - ovCtx.strokeRect(bb.x, bb.y, bb.w, bb.h); - ovCtx.setLineDash([]); + uiCtx.strokeStyle = "#FFF"; + uiCtx.setLineDash([4, 2]); + uiCtx.strokeRect(bbvp.x, bbvp.y, bbvp.w, bbvp.h); + uiCtx.setLineDash([]); // Draw Scaling/Rotation Origin - ovCtx.beginPath(); - ovCtx.arc( - state.selected.x + state.selected.w / 2, - state.selected.y + state.selected.h / 2, + uiCtx.beginPath(); + uiCtx.arc( + bbvp.x + bbvp.w / 2, + bbvp.y + bbvp.h / 2, 5, 0, 2 * Math.PI ); - ovCtx.stroke(); + uiCtx.stroke(); // Draw Scaling Handles let cursorInHandle = false; state.selected.handles().forEach((handle) => { + const bbvph = { + ...viewport.canvasToView(handle.x, handle.y), + w: viewport.zoom * handle.w, + h: viewport.zoom * handle.h, + }; if (handle.contains(evn.x, evn.y)) { cursorInHandle = true; - ovCtx.strokeRect( - handle.x - 1, - handle.y - 1, - handle.w + 2, - handle.h + 2 + uiCtx.strokeRect( + bbvph.x - 1, + bbvph.y - 1, + bbvph.w + 2, + bbvph.h + 2 ); } else { - ovCtx.strokeRect(handle.x, handle.y, handle.w, handle.h); + uiCtx.strokeRect(bbvph.x, bbvph.y, bbvph.w, bbvph.h); } }); @@ -287,15 +313,17 @@ const selectTransformTool = () => } // Draw current cursor location - ovCtx.lineWidth = 3; - ovCtx.strokeStyle = "#FFF"; + uiCtx.lineWidth = 3; + uiCtx.strokeStyle = "#FFF"; - ovCtx.beginPath(); - ovCtx.moveTo(x, y + 10); - ovCtx.lineTo(x, y - 10); - ovCtx.moveTo(x + 10, y); - ovCtx.lineTo(x - 10, y); - ovCtx.stroke(); + uiCtx.beginPath(); + uiCtx.moveTo(vpc.x, vpc.y + 10); + uiCtx.lineTo(vpc.x, vpc.y - 10); + uiCtx.moveTo(vpc.x + 10, vpc.y); + uiCtx.lineTo(vpc.x - 10, vpc.y); + uiCtx.stroke(); + + uiCtx.restore(); }; // Handles left mouse clicks @@ -312,7 +340,7 @@ const selectTransformTool = () => // If something is selected, commit changes to the canvas if (state.selected) { - imgCtx.drawImage( + uil.ctx.drawImage( state.selected.image, state.original.x, state.original.y @@ -330,7 +358,7 @@ const selectTransformTool = () => state.original = null; state.selected = null; - redraw(); + state.redraw(); } }; @@ -406,7 +434,7 @@ const selectTransformTool = () => const ctx = cvs.getContext("2d"); ctx.drawImage( - imgCanvas, + uil.canvas, state.selected.x, state.selected.y, state.selected.w, @@ -417,7 +445,7 @@ const selectTransformTool = () => state.selected.h ); - imgCtx.clearRect( + uil.ctx.clearRect( state.selected.x, state.selected.y, state.selected.w, @@ -431,7 +459,7 @@ const selectTransformTool = () => state.dragging = null; } - redraw(); + state.redraw(); }; // Handler for right clicks. Basically resets everything @@ -449,7 +477,7 @@ const selectTransformTool = () => state.selected && commands.runCommand("eraseImage", "Erase Area", state.selected); state.selected = null; - redraw(); + state.redraw(); } }; @@ -593,8 +621,10 @@ const selectTransformTool = () => createResourceButton.onclick = () => { const image = document.createElement("img"); image.src = state.selected.image.toDataURL(); - tools.stamp.state.addResource("Selection Resource", image); - tools.stamp.enable(); + image.onload = () => { + tools.stamp.state.addResource("Selection Resource", image); + tools.stamp.enable(); + }; }; actionArray.appendChild(saveSelectionButton); diff --git a/js/ui/tool/stamp.js b/js/ui/tool/stamp.js index 66e0c4b..faf0611 100644 --- a/js/ui/tool/stamp.js +++ b/js/ui/tool/stamp.js @@ -46,6 +46,8 @@ const stampTool = () => Array.from(state.ctxmenu.resourceList.children).forEach((child) => { child.classList.remove("selected"); }); + + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); }, { init: (state) => { @@ -157,15 +159,20 @@ const stampTool = () => actionArray.classList.add("actions"); const renameButton = document.createElement("button"); - renameButton.addEventListener("click", () => { - const name = prompt("Rename your resource:", resource.name); - if (name) { - resource.name = name; - resourceTitle.textContent = name; + renameButton.addEventListener( + "click", + (evn) => { + evn.stopPropagation(); + const name = prompt("Rename your resource:", resource.name); + if (name) { + resource.name = name; + resourceTitle.textContent = name; - syncResources(); - } - }); + syncResources(); + } + }, + {passive: false} + ); renameButton.title = "Rename Resource"; renameButton.appendChild(document.createElement("div")); renameButton.classList.add("rename-btn"); diff --git a/res/icons/chevron-down.svg b/res/icons/chevron-down.svg new file mode 100644 index 0000000..367a2bb --- /dev/null +++ b/res/icons/chevron-down.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/res/icons/chevron-first.svg b/res/icons/chevron-first.svg new file mode 100644 index 0000000..36cfa87 --- /dev/null +++ b/res/icons/chevron-first.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/icons/chevron-up.svg b/res/icons/chevron-up.svg new file mode 100644 index 0000000..7bfc938 --- /dev/null +++ b/res/icons/chevron-up.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/res/icons/eye-off.svg b/res/icons/eye-off.svg new file mode 100644 index 0000000..995e056 --- /dev/null +++ b/res/icons/eye-off.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/res/icons/eye.svg b/res/icons/eye.svg new file mode 100644 index 0000000..36329e0 --- /dev/null +++ b/res/icons/eye.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/icons/file-plus.svg b/res/icons/file-plus.svg new file mode 100644 index 0000000..1611710 --- /dev/null +++ b/res/icons/file-plus.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/res/icons/file-x.svg b/res/icons/file-x.svg new file mode 100644 index 0000000..f2339af --- /dev/null +++ b/res/icons/file-x.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file