diff --git a/.github/workflows/autoformat.yml b/.github/workflows/autoformat.yml index bad7bd1..f2ddba0 100644 --- a/.github/workflows/autoformat.yml +++ b/.github/workflows/autoformat.yml @@ -13,8 +13,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} - name: Prettify uses: creyD/prettier_action@v4.2 with: - ref: ${{ github.head_ref }} prettier_options: --write **/*.{js,html,css,md} diff --git a/css/index.css b/css/index.css index 4db5e50..9f6329a 100644 --- a/css/index.css +++ b/css/index.css @@ -1,6 +1,12 @@ * { font-size: 100%; font-family: Arial, Helvetica, sans-serif; + user-select: none; +} + +input, +textarea { + user-select: auto; } /* Body is stuck with no scroll */ @@ -136,7 +142,7 @@ body { color: #fff; } -#models { +.wideSelect { width: 100%; text-overflow: ellipsis; } @@ -146,59 +152,83 @@ body { position: relative; display: flex; - align-items: center; + align-items: stretch; justify-content: space-between; width: 100%; - height: fit-content; } .host-field-wrapper input { + flex-shrink: 0; + width: calc(100% - 15px); + display: block; + + border: 0; } .host-field-wrapper .connection-status { width: 15px; - height: 15px; + + position: absolute; + left: calc(100% - 15px); + + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; box-sizing: inherit; - border-radius: 50%; - margin: 5px; - cursor: pointer; - aspect-ratio: 1; + transition-duration: 50ms; + + padding-top: 1px; + padding-bottom: 1px; + + overflow: hidden; } - +.host-field-wrapper .connection-status:active, .host-field-wrapper .connection-status:hover { - width: 19px; - height: 19px; + width: fit-content; + padding-left: 5px; + padding-right: 6px; - margin: 3px; + filter: brightness(110%); } .host-field-wrapper .connection-status:active { - width: 17px; - height: 17px; + filter: brightness(80%); +} - margin: 4px; +.host-field-wrapper .connection-status > #connection-status-indicator-text { + opacity: 0%; + transition-duration: 20ms; +} + +.host-field-wrapper + .connection-status:hover + > #connection-status-indicator-text { + opacity: 100%; } .host-field-wrapper .connection-status.online { background-color: #49dd49; + color: #1f3f1f; } .host-field-wrapper .connection-status.offline { background-color: #dd4949; + color: #3f1f1f; } .host-field-wrapper .connection-status.cors-issue { background-color: #dddd49; + color: #3f3f1f; } .host-field-wrapper .connection-status.before { background-color: #777; + color: #1f1f1f; } input#host { @@ -222,6 +252,19 @@ div.prompt-wrapper > textarea:focus { width: 700px; } +/* Style Field */ +select > .style-select-option { + cursor: pointer; +} + +select > .style-select-option:hover { + background-color: #999; +} + +select > .style-select-option:active { + background-color: #aaa; +} + /* Tool buttons */ .button-array { display: flex; diff --git a/css/ui/generic.css b/css/ui/generic.css index deed60d..dd97f48 100644 --- a/css/ui/generic.css +++ b/css/ui/generic.css @@ -90,8 +90,26 @@ div.slider-wrapper > input.text { appearance: textfield; border: 0px; - padding-top: 5px; - height: 15px; + height: 100%; text-align: center; background-color: transparent; } + +/* Select Input */ +select > option:checked::after { + content: ""; + + position: absolute; + right: 5px; + top: 0; + + height: 100%; + aspect-ratio: 1; + + background-color: darkgreen; + + -webkit-mask-image: url("/res/icons/check.svg"); + -webkit-mask-size: contain; + mask-image: url("/res/icons/check.svg"); + mask-size: contain; +} diff --git a/css/ui/tool/stamp.css b/css/ui/tool/stamp.css index 2dc9a2d..a685abb 100644 --- a/css/ui/tool/stamp.css +++ b/css/ui/tool/stamp.css @@ -38,14 +38,69 @@ } .resource-manager > .resource-list > * { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } +.resource-manager > .resource-list > * > .resource-title { + overflow: hidden; + margin: 5px; + text-overflow: ellipsis; +} + +.resource-manager > .resource-list > * > .actions { + display: flex; + align-items: center; +} + +.resource-manager .actions > button { + display: flex; + align-items: stretch; + + padding: 0; + + width: 30px; + aspect-ratio: 1; + + background-color: transparent; + border: 0; + cursor: pointer; +} + +.resource-manager .actions > button:hover { + background-color: rgba(255, 255, 255, 0.5); +} + +.resource-manager .actions > button:active { + background-color: rgba(255, 255, 255, 0.7); +} + +.resource-manager .actions > button > *:first-child { + flex: 1; + margin: 3px; + + -webkit-mask-size: contain; + mask-size: contain; + background-color: var(--c-primary); +} + +.resource-manager .actions > .rename-btn > *:first-child { + -webkit-mask-image: url("/res/icons/edit.svg"); + mask-image: url("/res/icons/edit.svg"); +} + +.resource-manager .actions > .delete-btn > *:first-child { + -webkit-mask-image: url("/res/icons/trash.svg"); + mask-image: url("/res/icons/trash.svg"); +} + +.resource-manager > .resource-list > .selected:hover, .resource-manager > .resource-list > *:hover { - background-color: #ffff; + background-color: #fff8; } .resource-manager > .resource-list > .selected { background-color: #fff6; diff --git a/css/ui/toolbar.css b/css/ui/toolbar.css index 39c9341..52bd9bb 100644 --- a/css/ui/toolbar.css +++ b/css/ui/toolbar.css @@ -43,8 +43,6 @@ padding: 0; - background-color: var(--c-text); - right: 2px; top: 10px; @@ -78,6 +76,8 @@ border-radius: 5px; cursor: pointer; + + transition-duration: 50ms; } #ui-toolbar .tool.using { @@ -87,3 +87,8 @@ #ui-toolbar .tool:hover { background-color: var(--c-hover); } + +#ui-toolbar .tool:active { + background-color: var(--c-hover); + filter: brightness(120%); +} diff --git a/index.html b/index.html index 3e07789..317521b 100644 --- a/index.html +++ b/index.html @@ -36,7 +36,9 @@
+ class="connection-status before"> + Waiting + @@ -51,6 +53,12 @@
+ + @@ -59,7 +67,10 @@
- +
@@ -75,16 +86,15 @@ value="-1" step="1" />
+ + +
-
- - -

@@ -104,7 +114,7 @@
- + @@ -136,6 +146,10 @@
+ +
Alpha release v0.0.7.5 @@ -200,9 +214,8 @@ - - + - + + + diff --git a/js/index.js b/js/index.js index bf890f5..da9d063 100644 --- a/js/index.js +++ b/js/index.js @@ -24,6 +24,7 @@ var stableDiffusionData = { enable_hr: false, firstphase_width: 0, firstphase_height: 0, + styles: [], // here's some more fields that might be useful // ---txt2img specific: @@ -48,6 +49,7 @@ var stableDiffusionData = { }; // stuff things use +let debug = false; var returnedImages; var imageIndex = 0; var tmpImgXYWH = {}; @@ -58,7 +60,6 @@ var frameX = 512; var frameY = 512; var drawThis = {}; const basePixelCount = 64; //64 px - ALWAYS 64 PX -var scaleFactor = 8; //x64 px var snapToGrid = true; var backupMaskPaintCanvas; //??? var backupMaskPaintCtx; //...? look i am bad at this @@ -115,7 +116,6 @@ function startup() { changeSeed(); changeOverMaskPx(); changeHiResFix(); - document.getElementById("scaleFactor").value = scaleFactor; } /** @@ -126,6 +126,7 @@ function testHostConfiguration() { * Check host configuration */ const hostEl = document.getElementById("host"); + hostEl.value = localStorage.getItem("host"); const requestHost = (prompt, def = "http://127.0.0.1:7860") => { let value = window.prompt(prompt, def); @@ -168,6 +169,10 @@ async function testHostConnection() { let firstTimeOnline = true; const setConnectionStatus = (status) => { + const connectionIndicatorText = document.getElementById( + "connection-status-indicator-text" + ); + const statuses = { online: () => { connectionIndicator.classList.add("online"); @@ -177,7 +182,8 @@ async function testHostConnection() { "before", "server-error" ); - connectionIndicator.title = "Connected"; + connectionIndicatorText.textContent = connectionIndicator.title = + "Connected"; connectionStatus = true; }, error: () => { @@ -188,6 +194,7 @@ async function testHostConnection() { "before", "cors-issue" ); + connectionIndicatorText.textContent = "Error"; connectionIndicator.title = "Server is online, but is returning an error response"; connectionStatus = false; @@ -200,6 +207,7 @@ async function testHostConnection() { "before", "server-error" ); + connectionIndicatorText.textContent = "CORS"; connectionIndicator.title = "Server is online, but CORS is blocking our requests"; connectionStatus = false; @@ -212,6 +220,7 @@ async function testHostConnection() { "before", "server-error" ); + connectionIndicatorText.textContent = "Offline"; connectionIndicator.title = "Server seems to be offline. Please check the console for more information."; connectionStatus = false; @@ -224,6 +233,7 @@ async function testHostConnection() { "offline", "server-error" ); + connectionIndicatorText.textContent = "Waiting"; connectionIndicator.title = "Waiting for check to complete."; connectionStatus = false; }, @@ -254,6 +264,7 @@ async function testHostConnection() { setConnectionStatus("online"); // Load data as soon as connection is first stablished if (firstTimeOnline) { + getStyles(); getSamplers(); getUpscalers(); getModels(); @@ -272,10 +283,7 @@ async function testHostConnection() { await fetch(url, {mode: "no-cors"}); setConnectionStatus("corsissue"); - const message = `CORS is blocking our requests. Try running the webui with the flag '--cors-allow-origins=${document.URL.substring( - 0, - document.URL.length - 1 - )}'`; + const message = `CORS is blocking our requests. Try running the webui with the flag '--cors-allow-origins=${window.location.protocol}//${window.location.host}/'`; console.error(message); if (notify) alert(message); } catch (e) { @@ -484,24 +492,46 @@ const makeSlider = ( max, step, defaultValue, + textStep = null, valuecb = null ) => { - const local = localStorage.getItem(lsKey); + const local = lsKey && localStorage.getItem(lsKey); const def = parseFloat(local === null ? defaultValue : local); + let cb = (v) => { + stableDiffusionData[lsKey] = v; + if (lsKey) localStorage.setItem(lsKey, v); + }; + if (valuecb) { + cb = (v) => { + valuecb(v); + localStorage.setItem(lsKey, v); + }; + } return createSlider(label, el, { - valuecb: - valuecb || - ((v) => { - stableDiffusionData[lsKey] = v; - localStorage.setItem(lsKey, v); - }), + valuecb: cb, min, max, step, defaultValue: def, + textStep, }); }; +makeSlider( + "Resolution", + document.getElementById("resolution"), + "resolution", + 64, + 1024, + 64, + 512, + 2, + (v) => { + stableDiffusionData.width = stableDiffusionData.height = v; + stableDiffusionData.firstphase_width = + stableDiffusionData.firstphase_height = v / 2; + } +); makeSlider( "CFG Scale", document.getElementById("cfgScale"), @@ -509,7 +539,8 @@ makeSlider( -1, 25, 0.5, - 7.0 + 7.0, + 0.1 ); makeSlider( "Batch Size", @@ -529,27 +560,17 @@ makeSlider( 1, 2 ); -makeSlider( - "Scale Factor", - document.getElementById("scaleFactor"), - "scale_factor", - 1, - 16, - 1, - 8, - (v) => { - scaleFactor = v; - } -); -makeSlider("Steps", document.getElementById("steps"), "steps", 1, 70, 1, 30); +makeSlider("Steps", document.getElementById("steps"), "steps", 1, 70, 5, 30, 1); function changeSnapMode() { snapToGrid = document.getElementById("cbxSnap").checked; } function changeMaskBlur() { - stableDiffusionData.mask_blur = document.getElementById("maskBlur").value; + stableDiffusionData.mask_blur = parseInt( + document.getElementById("maskBlur").value + ); localStorage.setItem("mask_blur", stableDiffusionData.mask_blur); } @@ -742,6 +763,78 @@ function changeModel() { }); } +async function getStyles() { + /** @type {HTMLSelectElement} */ + var styleSelect = document.getElementById("styleSelect"); + var url = document.getElementById("host").value + "/sdapi/v1/prompt-styles"; + try { + const response = await fetch(url); + /** @type {{name: string, prompt: string, negative_prompt: string}[]} */ + const data = await response.json(); + + /** @type {string[]} */ + let stored = null; + try { + stored = JSON.parse(localStorage.getItem("promptStyle")); + // doesn't seem to throw a syntaxerror if the localstorage item simply doesn't exist? + if (stored == null) stored = []; + } catch (e) { + stored = []; + } + + data.forEach((style) => { + const option = document.createElement("option"); + option.classList.add("style-select-option"); + option.text = style.name; + option.value = style.name; + option.title = `prompt: ${style.prompt}\nnegative: ${style.negative_prompt}`; + if (stored.length === 0) option.selected = style.name === "None"; + else + option.selected = !!stored.find( + (styleName) => style.name === styleName + ); + + styleSelect.add(option); + }); + + changeStyles(); + + stored.forEach((styleName, index) => { + if (!data.findIndex((style) => style.name === styleName)) { + stored.splice(index, 1); + } + }); + localStorage.setItem("promptStyle", JSON.stringify(stored)); + } catch (e) { + console.warn("[index] Failed to fetch prompt styles"); + console.warn(e); + } +} + +function changeStyles() { + /** @type {HTMLSelectElement} */ + const styleSelectEl = document.getElementById("styleSelect"); + const selected = Array.from(styleSelectEl.options).filter( + (option) => option.selected + ); + let selectedString = selected.map((option) => option.value); + + if (selectedString.find((selected) => selected === "None")) { + selectedString = []; + Array.from(styleSelectEl.options).forEach((option) => { + if (option.value !== "None") option.selected = false; + }); + } + + localStorage.setItem("promptStyle", JSON.stringify(selectedString)); + + // change the model + if (selectedString.length > 0) + console.log(`[index] Changing styles to ${selectedString.join(", ")}`); + else console.log(`[index] Clearing styles`); + stableDiffusionData.styles = selectedString; +} + function getSamplers() { var samplerSelect = document.getElementById("samplerSelect"); var url = document.getElementById("host").value + "/sdapi/v1/samplers"; @@ -783,7 +876,7 @@ async function upscaleAndDownload() { var upscaler = document.getElementById("upscalers").value; var url = document.getElementById("host").value + "/sdapi/v1/extra-single-image/"; - var imgdata = croppedCanvas.toDataURL("image/png"); + var imgdata = croppedCanvas.canvas.toDataURL("image/png"); var data = { "resize-mode": 0, // 0 = just resize, 1 = crop and resize, 2 = resize and fill i assume based on theimg2img tabs options upscaling_resize: upscale_factor, @@ -880,3 +973,9 @@ imageCollection.element.addEventListener( }, {passive: false} ); + +function resetToDefaults() { + if (confirm("Are you sure you want to clear your settings?")) { + localStorage.clear(); + } +} diff --git a/js/initalize/debug.populate.js b/js/initalize/debug.populate.js index 8e40860..8033776 100644 --- a/js/initalize/debug.populate.js +++ b/js/initalize/debug.populate.js @@ -24,6 +24,11 @@ mouse.listen.world.onmousemove.on((evn) => { */ const toggledebug = () => { const hidden = debugCanvas.style.display === "none"; - if (hidden) debugLayer.unhide(); - else debugLayer.hide(); + if (hidden) { + debugLayer.unhide(); + debug = true; + } else { + debugLayer.hide(); + debug = false; + } }; diff --git a/js/initalize/layers.populate.js b/js/initalize/layers.populate.js index ca06b86..8c50a03 100644 --- a/js/initalize/layers.populate.js +++ b/js/initalize/layers.populate.js @@ -54,7 +54,7 @@ mouse.registerContext( const target = evn.target; // Get element bounding rect - const bb = target.getBoundingClientRect(); + const bb = imageCollection.element.getBoundingClientRect(); // Get element width/height (css, cause I don't trust client sizes in chrome anymore) const w = imageCollection.size.w; @@ -148,11 +148,13 @@ mouse.listen.window.onwheel.on((evn) => { viewport.transform(imageCollection.element); - debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); - debugCtx.fillStyle = "#F0F"; - debugCtx.beginPath(); - debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); - debugCtx.fill(); + if (debug) { + debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); + debugCtx.fillStyle = "#F0F"; + debugCtx.beginPath(); + debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); + debugCtx.fill(); + } } }); @@ -173,11 +175,13 @@ mouse.listen.window.btn.middle.onpaint.on((evn) => { } viewport.transform(imageCollection.element); - debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); - debugCtx.fillStyle = "#F0F"; - debugCtx.beginPath(); - debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); - debugCtx.fill(); + if (debug) { + debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); + debugCtx.fillStyle = "#F0F"; + debugCtx.beginPath(); + debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); + debugCtx.fill(); + } }); mouse.listen.window.btn.middle.onpaintend.on((evn) => { diff --git a/js/shortcuts.js b/js/initalize/shortcuts.populate.js similarity index 88% rename from js/shortcuts.js rename to js/initalize/shortcuts.populate.js index 8846ded..0c6cf0e 100644 --- a/js/shortcuts.js +++ b/js/initalize/shortcuts.populate.js @@ -14,6 +14,9 @@ keyboard.onShortcut({key: "KeyD"}, () => { keyboard.onShortcut({key: "KeyM"}, () => { tools.maskbrush.enable(); }); +keyboard.onShortcut({key: "KeyC"}, () => { + tools.colorbrush.enable(); +}); keyboard.onShortcut({key: "KeyI"}, () => { tools.img2img.enable(); }); diff --git a/js/initalize/toolbar.populate.js b/js/initalize/toolbar.populate.js index c7052df..37c622a 100644 --- a/js/initalize/toolbar.populate.js +++ b/js/initalize/toolbar.populate.js @@ -15,6 +15,7 @@ toolbar.addSeparator(); * Mask Brush tool */ tools.maskbrush = maskBrushTool(); +tools.colorbrush = colorBrushTool(); /** * Image Editing tools diff --git a/js/initalize/ui.populate.js b/js/initalize/ui.populate.js new file mode 100644 index 0000000..eed8070 --- /dev/null +++ b/js/initalize/ui.populate.js @@ -0,0 +1,35 @@ +document.querySelectorAll(".floating-window").forEach((w) => { + makeDraggable(w); +}); + +var coll = document.getElementsByClassName("collapsible"); +for (var i = 0; i < coll.length; i++) { + let active = false; + coll[i].addEventListener("click", function () { + var content = this.nextElementSibling; + + if (!active) { + this.classList.add("active"); + content.classList.add("active"); + } else { + this.classList.remove("active"); + content.classList.remove("active"); + } + + const observer = new ResizeObserver(() => { + if (active) content.style.maxHeight = content.scrollHeight + "px"; + }); + + Array.from(content.children).forEach((child) => { + observer.observe(child); + }); + + if (active) { + content.style.maxHeight = null; + active = false; + } else { + content.style.maxHeight = content.scrollHeight + "px"; + active = true; + } + }); +} diff --git a/js/lib/commands.js b/js/lib/commands.js index 20211b1..91bcaae 100644 --- a/js/lib/commands.js +++ b/js/lib/commands.js @@ -278,7 +278,30 @@ commands.createCommand( } // Apply command - state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h); + const style = state.context.fillStyle; + state.context.fillStyle = "black"; + + const op = state.context.globalCompositeOperation; + state.context.globalCompositeOperation = "destination-out"; + + if (options.mask) + state.context.drawImage( + options.mask, + state.box.x, + state.box.y, + state.box.w, + state.box.h + ); + else + state.context.fillRect( + state.box.x, + state.box.y, + state.box.w, + state.box.h + ); + + state.context.fillStyle = style; + state.context.globalCompositeOperation = op; }, (title, state) => { // Clear destination area diff --git a/js/lib/input.js b/js/lib/input.js index 5c9608b..4e625ee 100644 --- a/js/lib/input.js +++ b/js/lib/input.js @@ -288,7 +288,7 @@ window.addEventListener( window.addEventListener( "mousemove", (evn) => { - mouse._contexts.forEach((context) => { + mouse._contexts.forEach(async (context) => { const target = context.target; const name = context.name; diff --git a/js/lib/layers.js b/js/lib/layers.js index 6e38c9b..cede377 100644 --- a/js/lib/layers.js +++ b/js/lib/layers.js @@ -172,13 +172,30 @@ const layers = { * Moves this layer to another location * * @param {number} x X coordinate of the top left of the canvas - * @param {number} y X coordinate of the top left of the canvas + * @param {number} y Y coordinate of the top left of the canvas */ moveTo(x, y) { canvas.style.left = `${x}px`; canvas.style.top = `${y}px`; }, + /** + * Resizes layer in place + * + * @param {number} w New width + * @param {number} h New height + */ + resize(w, h) { + canvas.width = Math.round( + options.resolution.w * (w / options.bb.w) + ); + canvas.height = Math.round( + options.resolution.h * (h / options.bb.h) + ); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + }, + // Hides this layer (don't draw) hide() { this.canvas.style.display = "none"; diff --git a/js/lib/toolbar.js b/js/lib/toolbar.js index d6ae9d9..beda095 100644 --- a/js/lib/toolbar.js +++ b/js/lib/toolbar.js @@ -150,17 +150,20 @@ const _toolbar_input = { return {checkbox, label}; }, - slider: (state, dataKey, text, min = 0, max = 1, step = 0.1) => { + slider: (state, dataKey, text, options = {}) => { + defaultOpt(options, {min: 0, max: 1, step: 0.1, textStep: null, cb: null}); const slider = document.createElement("div"); const value = createSlider(text, slider, { - min, - max, - step, + min: options.min, + max: options.max, + step: options.step, valuecb: (v) => { state[dataKey] = v; + options.cb && options.cb(v); }, defaultValue: state[dataKey], + textStep: options.textStep, }); return { @@ -172,21 +175,3 @@ const _toolbar_input = { }; }, }; - -/** - * Dream and img2img tools - */ -const _reticle_draw = (evn, snapToGrid = true) => { - const bb = getBoundingBox( - evn.x, - evn.y, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor, - snapToGrid && basePixelCount - ); - - // 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 -}; diff --git a/js/settingsbar.js b/js/lib/ui.js similarity index 65% rename from js/settingsbar.js rename to js/lib/ui.js index f71bdb1..485f319 100644 --- a/js/settingsbar.js +++ b/js/lib/ui.js @@ -1,9 +1,18 @@ +/** + * This is a function that makes an HTMLElement draggable. + * + * The element must contain at least one child element with the class + * 'draggable', which will make it the handle for dragging the element + * + * @param {HTMLElement} element Element to make Draggable + */ function makeDraggable(element) { let dragging = false; let offset = {x: 0, y: 0}; const margin = 10; + // Keeps the draggable element inside the window const fixPos = () => { const dbb = element.getBoundingClientRect(); if (dbb.left < margin) element.style.left = margin + "px"; @@ -17,6 +26,7 @@ function makeDraggable(element) { dbb.top + (window.innerHeight - margin - dbb.bottom) + "px"; }; + // Detects the start of the mouse dragging event mouse.listen.window.btn.left.onpaintstart.on((evn) => { if ( element.contains(evn.target) && @@ -29,6 +39,7 @@ function makeDraggable(element) { } }); + // Runs when mouse moves mouse.listen.window.btn.left.onpaint.on((evn) => { if (dragging) { element.style.right = null; @@ -40,53 +51,30 @@ function makeDraggable(element) { } }); + // Stops dragging the element mouse.listen.window.btn.left.onpaintend.on((evn) => { dragging = false; }); + // Redraw after window resize window.addEventListener("resize", () => { fixPos(); }); } -document.querySelectorAll(".floating-window").forEach((w) => { - makeDraggable(w); -}); - -var coll = document.getElementsByClassName("collapsible"); -for (var i = 0; i < coll.length; i++) { - let active = false; - coll[i].addEventListener("click", function () { - var content = this.nextElementSibling; - - if (!active) { - this.classList.add("active"); - content.classList.add("active"); - } else { - this.classList.remove("active"); - content.classList.remove("active"); - } - - const observer = new ResizeObserver(() => { - if (active) content.style.maxHeight = content.scrollHeight + "px"; - }); - - Array.from(content.children).forEach((child) => { - observer.observe(child); - }); - - if (active) { - content.style.maxHeight = null; - active = false; - } else { - content.style.maxHeight = content.scrollHeight + "px"; - active = true; - } - }); -} - /** - * Slider Inputs + * Creates a custom slider element from a given div element + * + * @param {string} name The display name of the sliders + * @param {HTMLElement} wrapper The element to transform into a slider + * @param {object} options Extra options + * @param {number} options.min The minimum value of the slider + * @param {number} options.max The maximum value of the slider + * @param {number} options.step The step size for the slider + * @param {number} option.defaultValue The default value of the slider + * @param {number} [options.textStep=step] The step size for the slider text and setvalue \ + * (usually finer, and an integer divisor of step size) + * @returns {{value: number}} A reference to the value of the slider */ function createSlider(name, wrapper, options = {}) { defaultOpt(options, { @@ -95,6 +83,7 @@ function createSlider(name, wrapper, options = {}) { max: 1, step: 0.1, defaultValue: 0.7, + textStep: null, }); let value = options.defaultValue; @@ -106,6 +95,15 @@ function createSlider(name, wrapper, options = {}) { phantomRange.max = options.max; phantomRange.step = options.step; + let phantomTextRange = phantomRange; + if (options.textStep) { + phantomTextRange = document.createElement("input"); + phantomTextRange.type = "range"; + phantomTextRange.min = options.min; + phantomTextRange.max = options.max; + phantomTextRange.step = options.textStep; + } + // Build slider element const underEl = document.createElement("div"); underEl.classList.add("under"); @@ -128,8 +126,8 @@ function createSlider(name, wrapper, options = {}) { // Set value const setValue = (val) => { - phantomRange.value = val; - value = parseFloat(phantomRange.value); + phantomTextRange.value = val; + value = parseFloat(phantomTextRange.value); bar.style.width = `${ 100 * ((value - options.min) / (options.max - options.min)) }%`; @@ -170,17 +168,15 @@ function createSlider(name, wrapper, options = {}) { mouse.listen.window.btn.left.ondrag.on((evn) => { if (evn.initialTarget === overEl) { - setValue( - Math.max( - options.min, - Math.min( - options.max, - (evn.evn.layerX / wrapper.offsetWidth) * - (options.max - options.min) + - options.min - ) + phantomRange.value = Math.max( + options.min, + Math.min( + options.max, + (evn.evn.layerX / wrapper.offsetWidth) * (options.max - options.min) + + options.min ) ); + setValue(parseFloat(phantomRange.value)); } }); diff --git a/js/lib/util.js b/js/lib/util.js index 03f6a6d..5fa5f6d 100644 --- a/js/lib/util.js +++ b/js/lib/util.js @@ -145,21 +145,14 @@ function makeWriteOnce(obj, name = "write-once object", exceptions = []) { * Snaps a single value to an infinite grid * * @param {number} i Original value to be snapped - * @param {boolean} scaled If grid will change alignment for odd scaleFactor values (default: true) - * @param {number} gridSize Size of the grid + * @param {number} [offset=0] Value to offset the grid. Should be in the rande [0, gridSize[ + * @param {number} [gridSize=64] Size of the grid * @returns an offset, in which [i + offset = (a location snapped to the grid)] */ -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; - } - } - const modulus = i % gridSize; - var snapOffset = modulus - scaleOffset; +function snap(i, offset = 0, gridSize = 64) { + const modulus = (i - offset) % gridSize; + var snapOffset = modulus; + if (modulus > gridSize / 2) snapOffset = modulus - gridSize; if (snapOffset == 0) { @@ -175,19 +168,20 @@ function snap(i, scaled = true, gridSize = 64) { * @param {number} cy - y-coordinate of the center of the box * @param {number} w - the width of the box * @param {height} h - the height of the box - * @param {number | null} gridSnap - The size of the grid to snap to + * @param {?number} gridSnap - The size of the grid to snap to + * @param {number} [offset=0] - How much to offset the grid by * @returns {BoundingBox} - A bounding box object centered at (cx, cy) */ -function getBoundingBox(cx, cy, w, h, gridSnap = null) { - const offset = {x: 0, y: 0}; +function getBoundingBox(cx, cy, w, h, gridSnap = null, offset = 0) { + const offs = {x: 0, y: 0}; const box = {x: 0, y: 0}; if (gridSnap) { - offset.x = snap(cx, true, gridSnap); - offset.y = snap(cy, true, gridSnap); + offs.x = snap(cx, offset, gridSnap); + offs.y = snap(cy, offset, gridSnap); } - box.x = offset.x + cx; - box.y = offset.y + cy; + box.x = offs.x + cx; + box.y = offs.y + cy; return { x: Math.floor(box.x - w / 2), @@ -197,57 +191,58 @@ function getBoundingBox(cx, cy, w, h, gridSnap = null) { }; } +class NoContentError extends Error {} + /** * Crops a given canvas to content, returning a new canvas object with the content in it. * * @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from - * @returns {HTMLCanvasElement} A new canvas with the cropped part of the image + * @param {object} options Extra options + * @param {number} [options.border=0] Extra border around the content + * @returns {{canvas: HTMLCanvasElement, bb: BoundingBox}} A new canvas with the cropped part of the image */ -function cropCanvas(sourceCanvas) { - var w = sourceCanvas.width; - var h = sourceCanvas.height; - var pix = {x: [], y: []}; - var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h); - var x, y, index; +function cropCanvas(sourceCanvas, options = {}) { + defaultOpt(options, {border: 0}); - for (y = 0; y < h; y++) { - for (x = 0; x < w; x++) { + const w = sourceCanvas.width; + const h = sourceCanvas.height; + var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h); + /** @type {BoundingBox} */ + const bb = {x: 0, y: 0, w: 0, h: 0}; + + let minx = w; + let maxx = -1; + let miny = h; + let maxy = -1; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { // lol i need to learn what this part does - index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords + const index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords //this part i get, this is checking that 4th RGBA byte for opacity if (imageData.data[index + 3] > 0) { - pix.x.push(x); - pix.y.push(y); + minx = Math.min(minx, x); + maxx = Math.max(maxx, x); + miny = Math.min(miny, y); + maxy = Math.max(maxy, y); } } } - // ...need to learn what this part does too :badpokerface: - // is this just determining the boundaries of non-transparent pixel data? - pix.x.sort(function (a, b) { - return a - b; - }); - pix.y.sort(function (a, b) { - return a - b; - }); - var n = pix.x.length - 1; - w = pix.x[n] - pix.x[0] + 1; - h = pix.y[n] - pix.y[0] + 1; - // yup sure looks like it - try { - var cut = sourceCanvas - .getContext("2d") - .getImageData(pix.x[0], pix.y[0], w, h); - var cutCanvas = document.createElement("canvas"); - cutCanvas.width = w; - cutCanvas.height = h; - cutCanvas.getContext("2d").putImageData(cut, 0, 0); - } catch (ex) { - // probably empty image - //TODO confirm edge cases? - cutCanvas = null; - } - return cutCanvas; + bb.x = minx - options.border; + bb.y = miny - options.border; + bb.w = maxx - minx + 2 * options.border; + bb.h = maxy - miny + 2 * options.border; + + if (maxx < 0) throw new NoContentError("Canvas has no content to crop"); + + var cutCanvas = document.createElement("canvas"); + cutCanvas.width = bb.w; + cutCanvas.height = bb.h; + cutCanvas + .getContext("2d") + .drawImage(sourceCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + return {canvas: cutCanvas, bb}; } /** @@ -276,7 +271,7 @@ function downloadCanvas(options = {}) { if (options.filename) link.download = options.filename; var croppedCanvas = options.cropToContent - ? cropCanvas(options.canvas) + ? cropCanvas(options.canvas).canvas : options.canvas; if (croppedCanvas != null) { link.href = croppedCanvas.toDataURL("image/png"); diff --git a/js/ui/tool/colorbrush.js b/js/ui/tool/colorbrush.js new file mode 100644 index 0000000..0d1fba0 --- /dev/null +++ b/js/ui/tool/colorbrush.js @@ -0,0 +1,252 @@ +const _color_brush_draw_callback = (evn, state) => { + const ctx = state.drawLayer.ctx; + + ctx.strokeStyle = state.color; + + ctx.filter = "blur(" + state.brushBlur + "px)"; + ctx.lineWidth = state.brushSize; + ctx.beginPath(); + ctx.moveTo( + evn.px === undefined ? evn.x : evn.px, + evn.py === undefined ? evn.y : evn.py + ); + ctx.lineTo(evn.x, evn.y); + ctx.lineJoin = ctx.lineCap = "round"; + ctx.stroke(); +}; + +const _color_brush_erase_callback = (evn, state, ctx) => { + ctx.strokeStyle = "black"; + + ctx.lineWidth = state.brushSize; + ctx.beginPath(); + ctx.moveTo( + evn.px === undefined ? evn.x : evn.px, + evn.py === undefined ? evn.y : evn.py + ); + ctx.lineTo(evn.x, evn.y); + ctx.lineJoin = ctx.lineCap = "round"; + ctx.stroke(); +}; + +const colorBrushTool = () => + toolbar.registerTool( + "res/icons/brush.svg", + "Color Brush", + (state, opt) => { + // Draw new cursor immediately + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb({...mouse.coords.world.pos}); + state.drawLayer = imageCollection.registerLayer(null, { + after: imgLayer, + }); + state.eraseLayer = imageCollection.registerLayer(null, { + after: imgLayer, + }); + state.eraseLayer.canvas.style.display = "none"; + state.eraseBackup = imageCollection.registerLayer(null, { + after: imgLayer, + }); + state.eraseBackup.canvas.style.display = "none"; + + // Start Listeners + mouse.listen.world.onmousemove.on(state.movecb); + mouse.listen.world.onwheel.on(state.wheelcb); + + mouse.listen.world.btn.left.onpaintstart.on(state.drawstartcb); + mouse.listen.world.btn.left.onpaint.on(state.drawcb); + mouse.listen.world.btn.left.onpaintend.on(state.drawendcb); + + mouse.listen.world.btn.right.onpaintstart.on(state.erasestartcb); + mouse.listen.world.btn.right.onpaint.on(state.erasecb); + mouse.listen.world.btn.right.onpaintend.on(state.eraseendcb); + + // Display Color + setMask("none"); + }, + (state, opt) => { + // Clear Listeners + mouse.listen.world.onmousemove.clear(state.movecb); + mouse.listen.world.onwheel.clear(state.wheelcb); + + mouse.listen.world.btn.left.onpaintstart.clear(state.drawstartcb); + mouse.listen.world.btn.left.onpaint.clear(state.drawcb); + mouse.listen.world.btn.left.onpaintend.clear(state.drawendcb); + + mouse.listen.world.btn.right.onpaintstart.clear(state.erasestartcb); + mouse.listen.world.btn.right.onpaint.clear(state.erasecb); + mouse.listen.world.btn.right.onpaintend.clear(state.eraseendcb); + + // Delete layer + imageCollection.deleteLayer(state.drawLayer); + imageCollection.deleteLayer(state.eraseBackup); + imageCollection.deleteLayer(state.eraseLayer); + }, + { + init: (state) => { + state.config = { + brushScrollSpeed: 1 / 5, + minBrushSize: 2, + maxBrushSize: 500, + minBlur: 0, + maxBlur: 30, + }; + + state.color = "#FFFFFF"; + state.brushSize = 32; + state.brushBlur = 0; + state.affectMask = true; + state.setBrushSize = (size) => { + state.brushSize = size; + state.ctxmenu.brushSizeRange.value = size; + state.ctxmenu.brushSizeText.value = size; + }; + + state.movecb = (evn) => { + // draw big translucent white blob cursor + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + ovCtx.beginPath(); + ovCtx.arc(evn.x, evn.y, state.brushSize / 2, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line??? + ovCtx.fillStyle = state.color + "50"; + ovCtx.fill(); + }; + + state.wheelcb = (evn) => { + if (!evn.evn.ctrlKey) { + state.brushSize = state.setBrushSize( + state.brushSize - + Math.floor(state.config.brushScrollSpeed * evn.delta) + ); + ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); + state.movecb(evn); + } + }; + + state.drawstartcb = (evn) => { + if (state.affectMask) _mask_brush_draw_callback(evn, state); + _color_brush_draw_callback(evn, state); + }; + + state.drawcb = (evn) => { + if (state.affectMask) _mask_brush_draw_callback(evn, state); + _color_brush_draw_callback(evn, state); + }; + + state.drawendcb = (evn) => { + const canvas = state.drawLayer.canvas; + const ctx = state.drawLayer.ctx; + + const cropped = cropCanvas(canvas, {border: 10}); + const bb = cropped.bb; + commands.runCommand("drawImage", "Color Brush Draw", { + image: cropped.canvas, + ...bb, + }); + + ctx.clearRect(bb.x, bb.y, bb.w, bb.h); + }; + + state.erasestartcb = (evn) => { + if (state.affectMask) _mask_brush_erase_callback(evn, state); + + // Make a backup of the current image to apply erase later + const bkpcanvas = state.eraseBackup.canvas; + const bkpctx = state.eraseBackup.ctx; + bkpctx.clearRect(0, 0, bkpcanvas.width, bkpcanvas.height); + bkpctx.drawImage(imgCanvas, 0, 0); + + imgCtx.globalCompositeOperation = "destination-out"; + _color_brush_erase_callback(evn, state, imgCtx); + imgCtx.globalCompositeOperation = "source-over"; + _color_brush_erase_callback(evn, state, state.eraseLayer.ctx); + }; + + state.erasecb = (evn) => { + if (state.affectMask) _mask_brush_erase_callback(evn, state); + imgCtx.globalCompositeOperation = "destination-out"; + _color_brush_erase_callback(evn, state, imgCtx); + imgCtx.globalCompositeOperation = "source-over"; + _color_brush_erase_callback(evn, state, state.eraseLayer.ctx); + }; + + state.eraseendcb = (evn) => { + const canvas = state.eraseLayer.canvas; + const ctx = state.eraseLayer.ctx; + + const bkpcanvas = state.eraseBackup.canvas; + + const cropped = cropCanvas(canvas, {border: 10}); + const bb = cropped.bb; + + imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height); + imgCtx.drawImage(bkpcanvas, 0, 0); + + commands.runCommand("eraseImage", "Color Brush Erase", { + mask: cropped.canvas, + ...bb, + }); + + ctx.clearRect(bb.x, bb.y, bb.w, bb.h); + }; + }, + populateContextMenu: (menu, state) => { + if (!state.ctxmenu) { + state.ctxmenu = {}; + + // Affects Mask Checkbox + const affectMaskCheckbox = _toolbar_input.checkbox( + state, + "affectMask", + "Affect Mask" + ).label; + + state.ctxmenu.affectMaskCheckbox = affectMaskCheckbox; + + // Brush size slider + const brushSizeSlider = _toolbar_input.slider( + state, + "brushSize", + "Brush Size", + { + min: state.config.minBrushSize, + max: state.config.maxBrushSize, + step: 5, + textStep: 1, + } + ); + state.ctxmenu.brushSizeSlider = brushSizeSlider.slider; + state.setBrushSize = brushSizeSlider.setValue; + + // Brush size 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; + + // Brush color + const brushColorPicker = document.createElement("input"); + brushColorPicker.type = "color"; + brushColorPicker.style.width = "100%"; + brushColorPicker.value = state.color; + brushColorPicker.addEventListener("input", (evn) => { + state.color = evn.target.value; + }); + + state.ctxmenu.brushColorPicker = brushColorPicker; + } + + menu.appendChild(state.ctxmenu.affectMaskCheckbox); + menu.appendChild(state.ctxmenu.brushSizeSlider); + menu.appendChild(state.ctxmenu.brushBlurSlider); + menu.appendChild(state.ctxmenu.brushColorPicker); + }, + shortcut: "C", + } + ); diff --git a/js/ui/tool/dream.js b/js/ui/tool/dream.js index d980cef..fd37ac9 100644 --- a/js/ui/tool/dream.js +++ b/js/ui/tool/dream.js @@ -125,16 +125,40 @@ const _generate = async (endpoint, request, bb) => { image.src = "data:image/png;base64," + images[at]; image.addEventListener("load", () => { layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height); - if (images[at]) layer.ctx.drawImage(image, bb.x, bb.y); + if (images[at]) + layer.ctx.drawImage( + image, + 0, + 0, + image.width, + image.height, + bb.x, + bb.y, + bb.w, + bb.h + ); }); }; const stopMarchingAnts = march(bb); // First Dream Run - let stopProgress = _monitorProgress(bb); - images.push(...(await _dream(endpoint, requestCopy))); - stopProgress(); + console.info(`[dream] Generating images for prompt '${request.prompt}'`); + console.debug(request); + + let stopProgress = null; + try { + stopProgress = _monitorProgress(bb); + images.push(...(await _dream(endpoint, requestCopy))); + } catch (e) { + alert( + `Error generating images. Please try again or see consolde for more details` + ); + console.warn(`[dream] Error generating images:`); + console.warn(e); + } finally { + stopProgress(); + } // Image navigation const prevImg = () => { @@ -161,6 +185,8 @@ const _generate = async (endpoint, request, bb) => { commands.runCommand("drawImage", "Image Dream", { x: bb.x, y: bb.y, + w: bb.w, + h: bb.h, image: img, }); clean(true); @@ -168,11 +194,19 @@ const _generate = async (endpoint, request, bb) => { }; const makeMore = async () => { - let stopProgress = _monitorProgress(bb); - images.push(...(await _dream(endpoint, requestCopy))); - stopProgress(); - - imageindextxt.textContent = `${at + 1}/${images.length}`; + try { + stopProgress = _monitorProgress(bb); + images.push(...(await _dream(endpoint, requestCopy))); + imageindextxt.textContent = `${at + 1}/${images.length}`; + } catch (e) { + alert( + `Error generating images. Please try again or see consolde for more details` + ); + console.warn(`[dream] Error generating images:`); + console.warn(e); + } finally { + stopProgress(); + } }; const discardImg = async () => { @@ -316,8 +350,8 @@ const dream_generate_callback = async (evn, state) => { const bb = getBoundingBox( evn.x, evn.y, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor, + state.cursorSize, + state.cursorSize, state.snapToGrid && basePixelCount ); @@ -332,13 +366,6 @@ const dream_generate_callback = async (evn, state) => { // Don't allow another image until is finished blockNewImages = true; - // 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 @@ -355,13 +382,23 @@ const dream_generate_callback = async (evn, state) => { 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); + auxCtx.fillRect(0, 0, request.width, request.height); + auxCtx.drawImage( + imgCanvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + request.width, + request.height + ); request.init_images = [auxCanvas.toDataURL()]; // Get mask image auxCtx.fillStyle = "#000F"; - auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.fillRect(0, 0, request.width, request.height); if (state.invertMask) { // overmasking by definition is entirely pointless with an inverted mask outpaint // since it should explicitly avoid brushed masks too, we just won't even bother @@ -374,22 +411,42 @@ const dream_generate_callback = async (evn, state) => { bb.h, 0, 0, - bb.w, - bb.h + request.width, + request.height ); auxCtx.globalCompositeOperation = "destination-in"; - auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + auxCtx.drawImage( + imgCanvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + request.width, + request.height + ); } else { auxCtx.globalCompositeOperation = "destination-in"; - auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + auxCtx.drawImage( + imgCanvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + request.width, + request.height + ); // here's where to overmask to avoid including the brushed mask // 99% of my issues were from failing to set source-over for the overmask blotches if (state.overMaskPx > 0) { // transparent to white first auxCtx.globalCompositeOperation = "destination-atop"; auxCtx.fillStyle = "#FFFF"; - auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.fillRect(0, 0, request.width, request.height); applyOvermask(auxCanvas, auxCtx, state.overMaskPx); } @@ -402,13 +459,13 @@ const dream_generate_callback = async (evn, state) => { bb.h, 0, 0, - bb.w, - bb.h + request.width, + request.height ); } auxCtx.globalCompositeOperation = "destination-atop"; auxCtx.fillStyle = "#FFFF"; - auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.fillRect(0, 0, request.width, request.height); request.mask = auxCanvas.toDataURL(); // Dream _generate("img2img", request, bb); @@ -419,8 +476,8 @@ const dream_erase_callback = (evn, state) => { const bb = getBoundingBox( evn.x, evn.y, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor, + state.cursorSize, + state.cursorSize, state.snapToGrid && basePixelCount ); commands.runCommand("eraseImage", "Erase Area", bb); @@ -436,14 +493,25 @@ function applyOvermask(canvas, ctx, px) { if (ctxImgData.data[i] == 255) { // white pixel? // just blotch all over the thing - var rando = Math.floor(Math.random() * px); + /** + * This should probably have a better randomness profile for the overmasking + * + * Essentially, we want to have much more smaller values for randomness than big ones, + * because big values overshadow smaller circles and kinda ignores their randomness. + * + * And also, we want the profile to become more extreme the bigger the overmask size, + * because bigger px values also make bigger circles ocuppy more horizontal space. + */ + let lowRandom = + Math.atan(Math.random() * 10 - 10) / Math.abs(Math.atan(-10)) + 1; + lowRandom = Math.pow(lowRandom, px / 8); + + var rando = Math.floor(lowRandom * px); ctx.beginPath(); ctx.arc( (i / 4) % canvas.width, Math.floor(i / 4 / canvas.width), - scaleFactor + - rando + - (rando > scaleFactor ? rando / scaleFactor : scaleFactor / rando), // was 4 * sf + rando, too big, but i think i want it more ... random + rando, // was 4 * sf + rando, too big, but i think i want it more ... random 0, 2 * Math.PI, true @@ -462,8 +530,8 @@ const dream_img2img_callback = (evn, state) => { const bb = getBoundingBox( evn.x, evn.y, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor, + state.cursorSize, + state.cursorSize, state.snapToGrid && basePixelCount ); @@ -484,13 +552,6 @@ const dream_img2img_callback = (evn, state) => { // Don't allow another image until is finished blockNewImages = true; - // 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 img2img // Temporary canvas for init image and mask generation @@ -502,36 +563,56 @@ const dream_img2img_callback = (evn, state) => { 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); + auxCtx.fillRect(0, 0, request.width, request.height); + auxCtx.drawImage( + imgCanvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + request.width, + request.height + ); request.init_images = [auxCanvas.toDataURL()]; // Get mask image auxCtx.fillStyle = state.invertMask ? "#FFFF" : "#000F"; - auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.fillRect(0, 0, request.width, request.height); auxCtx.globalCompositeOperation = "destination-out"; - auxCtx.drawImage(maskPaintCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h); + auxCtx.drawImage( + maskPaintCanvas, + bb.x, + bb.y, + bb.w, + bb.h, + 0, + 0, + request.width, + request.height + ); auxCtx.globalCompositeOperation = "destination-atop"; auxCtx.fillStyle = state.invertMask ? "#000F" : "#FFFF"; - auxCtx.fillRect(0, 0, bb.w, bb.h); + auxCtx.fillRect(0, 0, request.width, request.height); // Border Mask if (state.keepBorderSize > 0) { auxCtx.globalCompositeOperation = "source-over"; auxCtx.fillStyle = "#000F"; - auxCtx.fillRect(0, 0, state.keepBorderSize, bb.h); - auxCtx.fillRect(0, 0, bb.w, state.keepBorderSize); + auxCtx.fillRect(0, 0, state.keepBorderSize, request.height); + auxCtx.fillRect(0, 0, request.width, state.keepBorderSize); auxCtx.fillRect( - bb.w - state.keepBorderSize, + request.width - state.keepBorderSize, 0, state.keepBorderSize, - bb.h + request.height ); auxCtx.fillRect( 0, - bb.h - state.keepBorderSize, - bb.w, + request.height - state.keepBorderSize, + request.width, state.keepBorderSize ); } @@ -544,6 +625,42 @@ const dream_img2img_callback = (evn, state) => { } }; +/** + * Dream and img2img tools + */ +const _reticle_draw = (evn, state) => { + const bb = getBoundingBox( + evn.x, + evn.y, + state.cursorSize, + state.cursorSize, + state.snapToGrid && basePixelCount + ); + + // 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 + + return () => { + ovCtx.clearRect(bb.x - 10, bb.y - 10, bb.w + 20, bb.h + 20); + }; +}; + +/** + * Generic wheel handler + */ + +const _dream_onwheel = (evn, state) => { + if (!evn.evn.ctrlKey) { + const v = + state.cursorSize - + Math.floor(state.config.cursorSizeScrollSpeed * evn.delta); + state.cursorSize = state.setCursorSize(v + snap(v, 0, 128)); + state.mousemovecb(evn); + } +}; + /** * Registers Tools */ @@ -560,6 +677,7 @@ const dreamTool = () => // Start Listeners mouse.listen.world.onmousemove.on(state.mousemovecb); + mouse.listen.world.onwheel.on(state.wheelcb); mouse.listen.world.btn.left.onclick.on(state.dreamcb); mouse.listen.world.btn.right.onclick.on(state.erasecb); @@ -569,6 +687,7 @@ const dreamTool = () => (state, opt) => { // Clear Listeners mouse.listen.world.onmousemove.clear(state.mousemovecb); + mouse.listen.world.onwheel.clear(state.wheelcb); mouse.listen.world.btn.left.onclick.clear(state.dreamcb); mouse.listen.world.btn.right.onclick.clear(state.erasecb); @@ -577,12 +696,25 @@ const dreamTool = () => }, { init: (state) => { + state.config = { + cursorSizeScrollSpeed: 1, + }; + + state.cursorSize = 512; + state.snapToGrid = true; state.invertMask = false; state.overMaskPx = 0; - state.mousemovecb = (evn) => { + + state.erasePrevReticle = () => ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - _reticle_draw(evn, state.snapToGrid); + + state.mousemovecb = (evn) => { + state.erasePrevReticle(); + state.erasePrevReticle = _reticle_draw(evn, state); + }; + state.wheelcb = (evn) => { + _dream_onwheel(evn, state); }; state.dreamcb = (evn) => { dream_generate_callback(evn, state); @@ -593,6 +725,22 @@ const dreamTool = () => if (!state.ctxmenu) { state.ctxmenu = {}; + // Cursor Size Slider + const cursorSizeSlider = _toolbar_input.slider( + state, + "cursorSize", + "Cursor Size", + { + min: 0, + max: 2048, + step: 128, + textStep: 2, + } + ); + + state.setCursorSize = cursorSizeSlider.setValue; + state.ctxmenu.cursorSizeSlider = cursorSizeSlider.slider; + // Snap to Grid Checkbox state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox( state, @@ -615,12 +763,16 @@ const dreamTool = () => state, "overMaskPx", "Overmask px", - 0, - 128, - 1 + { + min: 0, + max: 64, + step: 5, + textStep: 1, + } ).slider; } + menu.appendChild(state.ctxmenu.cursorSizeSlider); menu.appendChild(state.ctxmenu.snapToGridLabel); menu.appendChild(document.createElement("br")); menu.appendChild(state.ctxmenu.invertMaskLabel); @@ -644,6 +796,7 @@ const img2imgTool = () => // Start Listeners mouse.listen.world.onmousemove.on(state.mousemovecb); + mouse.listen.world.onwheel.on(state.wheelcb); mouse.listen.world.btn.left.onclick.on(state.dreamcb); mouse.listen.world.btn.right.onclick.on(state.erasecb); @@ -653,6 +806,7 @@ const img2imgTool = () => (state, opt) => { // Clear Listeners mouse.listen.world.onmousemove.clear(state.mousemovecb); + mouse.listen.world.onwheel.clear(state.wheelcb); mouse.listen.world.btn.left.onclick.clear(state.dreamcb); mouse.listen.world.btn.right.onclick.clear(state.erasecb); @@ -661,6 +815,11 @@ const img2imgTool = () => }, { init: (state) => { + state.config = { + cursorSizeScrollSpeed: 1, + }; + + state.cursorSize = 512; state.snapToGrid = true; state.invertMask = true; state.fullResolution = false; @@ -669,45 +828,63 @@ const img2imgTool = () => state.keepBorderSize = 64; - state.mousemovecb = (evn) => { + state.erasePrevReticle = () => ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - _reticle_draw(evn, state.snapToGrid); + + state.mousemovecb = (evn) => { + state.erasePrevReticle(); + state.erasePrevReticle = _reticle_draw(evn, state); const bb = getBoundingBox( evn.x, evn.y, - basePixelCount * scaleFactor, - basePixelCount * scaleFactor, + state.cursorSize, + state.cursorSize, state.snapToGrid && basePixelCount ); + // Resolution + const request = { + width: stableDiffusionData.width, + height: stableDiffusionData.height, + }; + // For displaying border mask const auxCanvas = document.createElement("canvas"); - auxCanvas.width = bb.w; - auxCanvas.height = bb.h; + auxCanvas.width = request.width; + auxCanvas.height = request.height; const auxCtx = auxCanvas.getContext("2d"); if (state.keepBorderSize > 0) { - auxCtx.fillStyle = "#6A6AFF7F"; - auxCtx.fillRect(0, 0, state.keepBorderSize, bb.h); - auxCtx.fillRect(0, 0, bb.w, state.keepBorderSize); + auxCtx.fillStyle = "#6A6AFF30"; + auxCtx.fillRect(0, 0, state.keepBorderSize, request.height); + auxCtx.fillRect(0, 0, request.width, state.keepBorderSize); auxCtx.fillRect( - bb.w - state.keepBorderSize, + request.width - state.keepBorderSize, 0, state.keepBorderSize, - bb.h + request.height ); auxCtx.fillRect( 0, - bb.h - state.keepBorderSize, - bb.w, + request.height - state.keepBorderSize, + request.width, state.keepBorderSize ); + ovCtx.drawImage( + auxCanvas, + 0, + 0, + request.width, + request.height, + bb.x, + bb.y, + bb.w, + bb.h + ); } - - const tmp = ovCtx.globalAlpha; - ovCtx.globalAlpha = 0.4; - ovCtx.drawImage(auxCanvas, bb.x, bb.y); - ovCtx.globalAlpha = tmp; + }; + state.wheelcb = (evn) => { + _dream_onwheel(evn, state); }; state.dreamcb = (evn) => { dream_img2img_callback(evn, state); @@ -717,6 +894,23 @@ const img2imgTool = () => populateContextMenu: (menu, state) => { if (!state.ctxmenu) { state.ctxmenu = {}; + + // Cursor Size Slider + const cursorSizeSlider = _toolbar_input.slider( + state, + "cursorSize", + "Cursor Size", + { + min: 0, + max: 2048, + step: 128, + textStep: 2, + } + ); + + state.setCursorSize = cursorSizeSlider.setValue; + state.ctxmenu.cursorSizeSlider = cursorSizeSlider.slider; + // Snap To Grid Checkbox state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox( state, @@ -746,9 +940,12 @@ const img2imgTool = () => state, "denoisingStrength", "Denoising Strength", - 0, - 1, - 0.05 + { + min: 0, + max: 1, + step: 0.05, + textStep: 0.01, + } ).slider; // Border Mask Size Slider @@ -756,12 +953,16 @@ const img2imgTool = () => state, "keepBorderSize", "Keep Border Size", - 0, - 128, - 1 + { + min: 0, + max: 128, + step: 8, + textStep: 1, + } ).slider; } + menu.appendChild(state.ctxmenu.cursorSizeSlider); menu.appendChild(state.ctxmenu.snapToGridLabel); menu.appendChild(document.createElement("br")); menu.appendChild(state.ctxmenu.invertMaskLabel); diff --git a/js/ui/tool/maskbrush.js b/js/ui/tool/maskbrush.js index 38a926c..6e6e8ce 100644 --- a/js/ui/tool/maskbrush.js +++ b/js/ui/tool/maskbrush.js @@ -52,11 +52,47 @@ const _mask_brush_erase_callback = (evn, state) => { 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([]); + } +}; + const maskBrushTool = () => toolbar.registerTool( "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}); @@ -73,6 +109,10 @@ 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); @@ -104,21 +144,22 @@ const maskBrushTool = () => state.preview = false; - state.movecb = (evn) => { - // draw big translucent white blob cursor + state.clearPrevCursor = () => ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); - ovCtx.beginPath(); - ovCtx.arc(evn.x, evn.y, state.brushSize / 2, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line??? - ovCtx.fillStyle = "#FFFFFF50"; - ovCtx.fill(); + state.movecb = (evn) => { + state.cursorLayer.moveTo( + evn.x - state.brushSize / 2 - 10, + evn.y - state.brushSize / 2 - 10 + ); - if (state.preview) { - ovCtx.strokeStyle = "#000F"; - ovCtx.setLineDash([4, 2]); - ovCtx.stroke(); - ovCtx.setLineDash([]); - } + 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.wheelcb = (evn) => { @@ -127,7 +168,6 @@ const maskBrushTool = () => state.brushSize - Math.floor(state.config.brushScrollSpeed * evn.delta) ); - ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); state.movecb(evn); } }; @@ -142,9 +182,16 @@ const maskBrushTool = () => state, "brushSize", "Brush Size", - state.config.minBrushSize, - state.config.maxBrushSize, - 1 + { + min: state.config.minBrushSize, + max: state.config.maxBrushSize, + step: 5, + textStep: 1, + cb: (v) => { + if (!state.cursorLayer) return; + _paint_mb_cursor(state); + }, + } ); state.ctxmenu.brushSizeSlider = brushSizeSlider.slider; state.setBrushSize = brushSizeSlider.setValue; @@ -174,9 +221,11 @@ const maskBrushTool = () => if (previewMaskButton.classList.contains("active")) { maskPaintCanvas.classList.remove("opaque"); state.preview = false; + _paint_mb_cursor(state); } else { maskPaintCanvas.classList.add("opaque"); state.preview = true; + _paint_mb_cursor(state); } previewMaskButton.classList.toggle("active"); }; diff --git a/js/ui/tool/select.js b/js/ui/tool/select.js index 92e667b..cec7c45 100644 --- a/js/ui/tool/select.js +++ b/js/ui/tool/select.js @@ -54,7 +54,9 @@ const selectTransformTool = () => state.snapToGrid = true; state.keepAspectRatio = true; - state.useClipboard = !!navigator.clipboard.write; // Use it by default if supported + state.useClipboard = !!( + navigator.clipboard && navigator.clipboard.write + ); // Use it by default if supported state.original = null; state.dragging = null; @@ -192,8 +194,8 @@ const selectTransformTool = () => let x = evn.x; let y = evn.y; if (state.snapToGrid) { - x += snap(evn.x, true, 64); - y += snap(evn.y, true, 64); + x += snap(evn.x, 0, 64); + y += snap(evn.y, 0, 64); } // Update scale @@ -337,8 +339,8 @@ const selectTransformTool = () => let ix = evn.ix; let iy = evn.iy; if (state.snapToGrid) { - ix += snap(evn.ix, true, 64); - iy += snap(evn.iy, true, 64); + ix += snap(evn.ix, 0, 64); + iy += snap(evn.iy, 0, 64); } // If is selected, check if drag is in handles/body and act accordingly @@ -368,8 +370,8 @@ const selectTransformTool = () => let x = evn.x; let y = evn.y; if (state.snapToGrid) { - x += snap(evn.x, true, 64); - y += snap(evn.y, true, 64); + x += snap(evn.x, 0, 64); + y += snap(evn.y, 0, 64); } // If we are scaling, stop scaling and do some handler magic @@ -489,10 +491,11 @@ const selectTransformTool = () => // Send to clipboard state.clipboard.copy.toBlob((blob) => { const item = new ClipboardItem({"image/png": blob}); - navigator.clipboard.write([item]).catch((e) => { - console.warn("Error sending to clipboard"); - console.warn(e); - }); + navigator.clipboard && + navigator.clipboard.write([item]).catch((e) => { + console.warn("Error sending to clipboard"); + console.warn(e); + }); }); } }; @@ -501,25 +504,25 @@ const selectTransformTool = () => state.ctrlvcb = (evn) => { if (state.useClipboard) { // If we use the clipboard, do some proccessing of clipboard data (ugly but kind of minimum required) - navigator.clipboard.read().then((items) => { - console.info(items[0]); - for (const item of items) { - for (const type of item.types) { - if (type.startsWith("image/")) { - item.getType(type).then((blob) => { - // Converts blob to image - const url = window.URL || window.webkitURL; - const image = document.createElement("img"); - image.src = url.createObjectURL(file); - tools.stamp.enable({ - image, - back: tools.selecttransform.enable, + navigator.clipboard && + navigator.clipboard.read().then((items) => { + for (const item of items) { + for (const type of item.types) { + if (type.startsWith("image/")) { + item.getType(type).then((blob) => { + // Converts blob to image + const url = window.URL || window.webkitURL; + const image = document.createElement("img"); + image.src = url.createObjectURL(file); + tools.stamp.enable({ + image, + back: tools.selecttransform.enable, + }); }); - }); + } } } - } - }); + }); } else if (state.clipboard.copy) { // Use internal clipboard const image = document.createElement("img"); @@ -563,7 +566,7 @@ const selectTransformTool = () => "Use clipboard" ); state.ctxmenu.useClipboardLabel = clipboardCheckbox.label; - if (!navigator.clipboard.write) + if (!(navigator.clipboard && navigator.clipboard.write)) clipboardCheckbox.checkbox.disabled = true; // Disable if not available // Some useful actions to do with selection diff --git a/js/ui/tool/stamp.js b/js/ui/tool/stamp.js index f5057ab..70c507b 100644 --- a/js/ui/tool/stamp.js +++ b/js/ui/tool/stamp.js @@ -19,7 +19,8 @@ const stampTool = () => state.addResource( opt.name || "Clipboard", opt.image, - opt.temporary === undefined ? true : opt.temporary + opt.temporary === undefined ? true : opt.temporary, + false ); state.ctxmenu.uploadButton.disabled = true; state.back = opt.back || null; @@ -56,8 +57,14 @@ const stampTool = () => state.lastMouseMove = {x: 0, y: 0}; - state.selectResource = (resource) => { - if (state.ctxmenu.uploadButton.disabled) return; + state.selectResource = (resource, nolock = true) => { + if (nolock && state.ctxmenu.uploadButton.disabled) return; + + console.debug( + `[stamp] Selecting Resource '${resource && resource.name}'[${ + resource && resource.id + }]` + ); const resourceWrapper = resource && resource.dom.wrapper; @@ -83,8 +90,38 @@ const stampTool = () => if (state.loaded) state.movecb(state.lastMouseMove); }; - // Synchronizes resources array with the DOM + // Synchronizes resources array with the DOM and Local Storage const syncResources = () => { + // Saves to local storage + try { + localStorage.setItem( + "tools.stamp.resources", + JSON.stringify( + state.resources + .filter((resource) => !resource.temporary) + .map((resource) => { + const canvas = document.createElement("canvas"); + canvas.width = resource.image.width; + canvas.height = resource.image.height; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(resource.image, 0, 0); + + return { + id: resource.id, + name: resource.name, + src: canvas.toDataURL(), + }; + }) + ) + ); + } catch (e) { + console.warn( + "[stamp] Failed to synchronize resources with local storage" + ); + console.warn(e); + } + // Creates DOM elements when needed state.resources.forEach((resource) => { if ( @@ -93,12 +130,15 @@ const stampTool = () => ) ) { console.debug( - `Creating resource element 'resource-${resource.id}'` + `[stamp] Creating Resource Element [resource-${resource.id}]` ); const resourceWrapper = document.createElement("div"); resourceWrapper.id = `resource-${resource.id}`; - resourceWrapper.textContent = resource.name; resourceWrapper.classList.add("resource"); + const resourceTitle = document.createElement("span"); + resourceTitle.textContent = resource.name; + resourceTitle.classList.add("resource-title"); + resourceWrapper.appendChild(resourceTitle); resourceWrapper.addEventListener("click", () => state.selectResource(resource) @@ -112,6 +152,41 @@ const stampTool = () => state.ctxmenu.previewPane.style.display = "none"; }); + // Add action buttons + const actionArray = document.createElement("div"); + 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; + + syncResources(); + } + }); + renameButton.title = "Rename Resource"; + renameButton.appendChild(document.createElement("div")); + renameButton.classList.add("rename-btn"); + + const trashButton = document.createElement("button"); + trashButton.addEventListener( + "click", + (evn) => { + evn.stopPropagation(); + state.ctxmenu.previewPane.style.display = "none"; + state.deleteResource(resource.id); + }, + {passive: false} + ); + trashButton.title = "Delete Resource"; + trashButton.appendChild(document.createElement("div")); + trashButton.classList.add("delete-btn"); + + actionArray.appendChild(renameButton); + actionArray.appendChild(trashButton); + resourceWrapper.appendChild(actionArray); state.ctxmenu.resourceList.appendChild(resourceWrapper); resource.dom = {wrapper: resourceWrapper}; } @@ -124,15 +199,20 @@ const stampTool = () => elements.forEach((element) => { let remove = true; state.resources.some((resource) => { - if (element.id.endsWith(resource.id)) remove = false; + if (element.id.endsWith(resource.id)) { + remove = false; + } }); - if (remove) state.ctxmenu.resourceList.removeChild(element); + if (remove) { + console.debug(`[stamp] Sync Removing Element [${element.id}]`); + state.ctxmenu.resourceList.removeChild(element); + } }); }; // Adds a image resource (temporary allows only one draw, used for pasting) - state.addResource = (name, image, temporary = false) => { + state.addResource = (name, image, temporary = false, nolock = true) => { const id = guid(); const resource = { id, @@ -140,19 +220,28 @@ const stampTool = () => image, temporary, }; + + console.info(`[stamp] Adding Resource '${name}'[${id}]`); + state.resources.push(resource); syncResources(); // Select this resource - state.selectResource(resource); + state.selectResource(resource, nolock); return resource; }; - // Deletes a resource (Yes, functionality is here, but we don't have an UI for this yet) // Used for temporary images too state.deleteResource = (id) => { - state.resources = state.resources.filter((v) => v.id !== id); + const resourceIndex = state.resources.findIndex((v) => v.id === id); + const resource = state.resources[resourceIndex]; + if (state.selected === resource) state.selected = null; + console.info( + `[stamp] Deleting Resource '${resource.name}'[${resource.id}]` + ); + + state.resources.splice(resourceIndex, 1); syncResources(); }; @@ -161,8 +250,8 @@ const stampTool = () => let x = evn.x; let y = evn.y; if (state.snapToGrid) { - x += snap(evn.x, true, 64); - y += snap(evn.y, true, 64); + x += snap(evn.x, 0, 64); + y += snap(evn.y, 0, 64); } state.lastMouseMove = evn; @@ -190,8 +279,8 @@ const stampTool = () => let x = evn.x; let y = evn.y; if (state.snapToGrid) { - x += snap(evn.x, true, 64); - y += snap(evn.y, true, 64); + x += snap(evn.x, 0, 64); + y += snap(evn.y, 0, 64); } const resource = state.selected; @@ -203,7 +292,9 @@ const stampTool = () => y, }); - if (resource.temporary) state.deleteResource(resource.id); + if (resource.temporary) { + state.deleteResource(resource.id); + } } if (state.back) { @@ -268,7 +359,7 @@ const stampTool = () => const image = document.createElement("img"); image.src = url.createObjectURL(file); - state.addResource(file.name, image, false); + image.onload = () => state.addResource(file.name, image, false); } }); @@ -321,6 +412,29 @@ const stampTool = () => state.ctxmenu.previewPane = previewPane; state.ctxmenu.resourceManager = resourceManager; state.ctxmenu.resourceList = resourceList; + + // Performs resource fetch from local storage + { + const storageResources = localStorage.getItem( + "tools.stamp.resources" + ); + if (storageResources) { + const parsed = JSON.parse(storageResources); + state.resources.push( + ...parsed.map((resource) => { + const image = document.createElement("img"); + image.src = resource.src; + + return { + id: resource.id, + name: resource.name, + image, + }; + }) + ); + syncResources(); + } + } } }, populateContextMenu: (menu, state) => { diff --git a/res/icons/brush.svg b/res/icons/brush.svg new file mode 100644 index 0000000..a4ddbd1 --- /dev/null +++ b/res/icons/brush.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/icons/check.svg b/res/icons/check.svg new file mode 100644 index 0000000..16acfeb --- /dev/null +++ b/res/icons/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/res/icons/edit.svg b/res/icons/edit.svg new file mode 100644 index 0000000..aafb5ce --- /dev/null +++ b/res/icons/edit.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/icons/trash.svg b/res/icons/trash.svg new file mode 100644 index 0000000..4ce815a --- /dev/null +++ b/res/icons/trash.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file