Merge pull request #97 from zero01101/testing
Pull Request for 2022-12-12
This commit is contained in:
commit
74041b78a8
16 changed files with 920 additions and 363 deletions
231
css/index.css
231
css/index.css
|
@ -331,13 +331,48 @@ input#host {
|
||||||
|
|
||||||
/* Prompt Fields */
|
/* Prompt Fields */
|
||||||
|
|
||||||
|
.content.prompt {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content.prompt > .inputs {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
div.prompt-wrapper {
|
div.prompt-wrapper {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
width: calc(100%);
|
||||||
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.prompt-wrapper > textarea {
|
div.prompt-wrapper > * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper textarea {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper:not(:first-child) textarea {
|
||||||
|
border-top: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
transition-duration: 200ms;
|
||||||
|
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
@ -346,6 +381,198 @@ div.prompt-wrapper > textarea:focus {
|
||||||
width: 700px;
|
width: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
cursor: help;
|
||||||
|
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper:first-child > .prompt-indicator {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper:last-child > .prompt-indicator {
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator.positive {
|
||||||
|
background-color: #484;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator.negative {
|
||||||
|
background-color: #844;
|
||||||
|
}
|
||||||
|
div.prompt-wrapper > .prompt-indicator.styles {
|
||||||
|
background-color: #448;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
background-color: var(--c-text);
|
||||||
|
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator.positive::after {
|
||||||
|
mask-image: url("/res/icons/plus-square.svg");
|
||||||
|
-webkit-mask-image: url("/res/icons/plus-square.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator.negative::after {
|
||||||
|
mask-image: url("/res/icons/minus-square.svg");
|
||||||
|
-webkit-mask-image: url("/res/icons/minus-square.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
div.prompt-wrapper > .prompt-indicator.styles::after {
|
||||||
|
mask-image: url("/res/icons/library.svg");
|
||||||
|
-webkit-mask-image: url("/res/icons/library.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-history-wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-history-container {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history {
|
||||||
|
width: 0px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
transition-duration: 200ms;
|
||||||
|
|
||||||
|
background-color: #1e1e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history.expanded {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
border: 1px #fff3;
|
||||||
|
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history.expanded .entry > button {
|
||||||
|
padding: 2px;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry > button {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
color: var(--c-text);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
transition-duration: 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry:hover > button:not(:hover) {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-basis: 20%;
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry > button.prompt {
|
||||||
|
background-color: #484;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry > button.negative {
|
||||||
|
background-color: #844;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry > button.styles {
|
||||||
|
background-color: #448;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry > button:hover {
|
||||||
|
filter: brightness(115%);
|
||||||
|
backdrop-filter: brightness(115%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-history .entry > button:active {
|
||||||
|
filter: brightness(150%);
|
||||||
|
backdrop-filter: brightness(150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.prompt-history-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
|
||||||
|
background-color: #1e1e50;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.prompt-history-btn::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
background-color: var(--c-text);
|
||||||
|
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
|
||||||
|
mask-image: url("/res/icons/history.svg");
|
||||||
|
-webkit-mask-image: url("/res/icons/history.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
button.prompt-history-btn:hover {
|
||||||
|
filter: brightness(115%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.prompt-history-btn:active {
|
||||||
|
filter: brightness(150%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Style Field */
|
/* Style Field */
|
||||||
select > .style-select-option {
|
select > .style-select-option {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
.dream-interrupt-btn {
|
.dream-stop-btn {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
37
index.html
37
index.html
|
@ -51,19 +51,31 @@
|
||||||
</label>
|
</label>
|
||||||
<!-- Prompts section -->
|
<!-- Prompts section -->
|
||||||
<button type="button" class="collapsible">Prompts</button>
|
<button type="button" class="collapsible">Prompts</button>
|
||||||
<div class="content">
|
<div class="content prompt">
|
||||||
<label for="prompt">Prompt:</label>
|
<div class="inputs">
|
||||||
<br />
|
<div class="prompt-wrapper">
|
||||||
<div class="prompt-wrapper">
|
<div class="prompt-indicator positive" title="Prompt"></div>
|
||||||
<textarea id="prompt"></textarea>
|
<textarea id="prompt" class="expandable"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-wrapper">
|
||||||
|
<div
|
||||||
|
class="prompt-indicator negative"
|
||||||
|
title="Negative Prompt"></div>
|
||||||
|
<textarea id="negPrompt" class="expandable"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-wrapper">
|
||||||
|
<div class="prompt-indicator styles" title="Styles"></div>
|
||||||
|
<div id="style-ac-mselect" style="flex-shrink: 1"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label for="negPrompt">Negative prompt:</label>
|
<div class="prompt-history-wrapper">
|
||||||
<div class="prompt-wrapper">
|
<div class="prompt-history-container">
|
||||||
<textarea id="negPrompt"></textarea>
|
<div id="prompt-history"></div>
|
||||||
|
<button
|
||||||
|
id="prompt-history-btn"
|
||||||
|
class="prompt-history-btn"></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label for="styleSelect">Styles:</label>
|
|
||||||
<div id="style-ac-mselect"></div>
|
|
||||||
<!-- <hr /> -->
|
|
||||||
</div>
|
</div>
|
||||||
<!-- SD section -->
|
<!-- SD section -->
|
||||||
<button type="button" class="collapsible">
|
<button type="button" class="collapsible">
|
||||||
|
@ -286,6 +298,7 @@
|
||||||
|
|
||||||
<!-- Base Libs -->
|
<!-- Base Libs -->
|
||||||
<script src="js/lib/util.js" type="text/javascript"></script>
|
<script src="js/lib/util.js" type="text/javascript"></script>
|
||||||
|
<script src="js/lib/events.js" type="text/javascript"></script>
|
||||||
<script src="js/lib/input.js" type="text/javascript"></script>
|
<script src="js/lib/input.js" type="text/javascript"></script>
|
||||||
<script src="js/lib/layers.js" type="text/javascript"></script>
|
<script src="js/lib/layers.js" type="text/javascript"></script>
|
||||||
<script src="js/lib/commands.js" type="text/javascript"></script>
|
<script src="js/lib/commands.js" type="text/javascript"></script>
|
||||||
|
@ -298,7 +311,9 @@
|
||||||
type="text/javascript"></script>
|
type="text/javascript"></script>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
|
<script src="js/prompt.js" type="text/javascript"></script>
|
||||||
<script src="js/index.js" type="text/javascript"></script>
|
<script src="js/index.js" type="text/javascript"></script>
|
||||||
|
|
||||||
<script src="js/ui/floating/history.js" type="text/javascript"></script>
|
<script src="js/ui/floating/history.js" type="text/javascript"></script>
|
||||||
<script src="js/ui/floating/layers.js" type="text/javascript"></script>
|
<script src="js/ui/floating/layers.js" type="text/javascript"></script>
|
||||||
|
|
||||||
|
|
67
js/index.js
67
js/index.js
|
@ -96,20 +96,6 @@ function startup() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const promptEl = document.getElementById("prompt");
|
|
||||||
promptEl.oninput = () => {
|
|
||||||
stableDiffusionData.prompt = promptEl.value;
|
|
||||||
promptEl.title = promptEl.value;
|
|
||||||
localStorage.setItem("prompt", stableDiffusionData.prompt);
|
|
||||||
};
|
|
||||||
|
|
||||||
const negPromptEl = document.getElementById("negPrompt");
|
|
||||||
negPromptEl.oninput = () => {
|
|
||||||
stableDiffusionData.negative_prompt = negPromptEl.value;
|
|
||||||
negPromptEl.title = negPromptEl.value;
|
|
||||||
localStorage.setItem("neg_prompt", stableDiffusionData.negative_prompt);
|
|
||||||
};
|
|
||||||
|
|
||||||
drawBackground();
|
drawBackground();
|
||||||
changeMaskBlur();
|
changeMaskBlur();
|
||||||
changeSmoothRendering();
|
changeSmoothRendering();
|
||||||
|
@ -447,12 +433,6 @@ const makeSlider = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const styleAutoComplete = createAutoComplete(
|
|
||||||
"Style",
|
|
||||||
document.getElementById("style-ac-mselect"),
|
|
||||||
{multiple: true}
|
|
||||||
);
|
|
||||||
|
|
||||||
const modelAutoComplete = createAutoComplete(
|
const modelAutoComplete = createAutoComplete(
|
||||||
"Model",
|
"Model",
|
||||||
document.getElementById("models-ac-select")
|
document.getElementById("models-ac-select")
|
||||||
|
@ -784,49 +764,6 @@ async function getConfig() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
styleAutoComplete.options = data.map((style) => ({
|
|
||||||
name: style.name,
|
|
||||||
value: style.name,
|
|
||||||
title: `prompt: ${style.prompt}\nnegative: ${style.negative_prompt}`,
|
|
||||||
}));
|
|
||||||
styleAutoComplete.onchange.on(({value}) => {
|
|
||||||
let selected = [];
|
|
||||||
if (value.find((v) => v === "None")) {
|
|
||||||
styleAutoComplete.value = [];
|
|
||||||
} else {
|
|
||||||
selected = value;
|
|
||||||
}
|
|
||||||
stableDiffusionData.styles = selected;
|
|
||||||
localStorage.setItem("promptStyle", JSON.stringify(selected));
|
|
||||||
});
|
|
||||||
|
|
||||||
styleAutoComplete.value = stored;
|
|
||||||
localStorage.setItem("promptStyle", JSON.stringify(stored));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[index] Failed to fetch prompt styles");
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeStyles() {
|
function changeStyles() {
|
||||||
/** @type {HTMLSelectElement} */
|
/** @type {HTMLSelectElement} */
|
||||||
const styleSelectEl = document.getElementById("styleSelect");
|
const styleSelectEl = document.getElementById("styleSelect");
|
||||||
|
@ -958,10 +895,6 @@ function loadSettings() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// set the values into the UI
|
// set the values into the UI
|
||||||
document.getElementById("prompt").value = String(_prompt);
|
|
||||||
document.getElementById("prompt").title = String(_prompt);
|
|
||||||
document.getElementById("negPrompt").value = String(_negprompt);
|
|
||||||
document.getElementById("negPrompt").title = String(_negprompt);
|
|
||||||
document.getElementById("maskBlur").value = Number(_mask_blur);
|
document.getElementById("maskBlur").value = Number(_mask_blur);
|
||||||
document.getElementById("seed").value = Number(_seed);
|
document.getElementById("seed").value = Number(_seed);
|
||||||
document.getElementById("cbxHRFix").checked = Boolean(_enable_hr);
|
document.getElementById("cbxHRFix").checked = Boolean(_enable_hr);
|
||||||
|
|
|
@ -48,7 +48,7 @@ for (var i = 0; i < coll.length; i++) {
|
||||||
if (active) content.style.maxHeight = content.scrollHeight + "px";
|
if (active) content.style.maxHeight = content.scrollHeight + "px";
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.from(content.children).forEach((child) => {
|
Array.from(content.querySelectorAll("*")).forEach((child) => {
|
||||||
observer.observe(child);
|
observer.observe(child);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -62,6 +62,20 @@ for (var i = 0; i < coll.length; i++) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt history setup
|
||||||
|
*/
|
||||||
|
const _promptHistoryEl = document.getElementById("prompt-history");
|
||||||
|
const _promptHistoryBtn = document.getElementById("prompt-history-btn");
|
||||||
|
|
||||||
|
_promptHistoryEl.addEventListener("mouseleave", () => {
|
||||||
|
_promptHistoryEl.classList.remove("expanded");
|
||||||
|
});
|
||||||
|
|
||||||
|
_promptHistoryBtn.addEventListener("click", () =>
|
||||||
|
_promptHistoryEl.classList.toggle("expanded")
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings overlay setup
|
* Settings overlay setup
|
||||||
*/
|
*/
|
||||||
|
|
5
js/lib/events.js
Normal file
5
js/lib/events.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
const events = makeReadOnly({
|
||||||
|
tool: {
|
||||||
|
dream: new Observer(),
|
||||||
|
},
|
||||||
|
});
|
|
@ -80,6 +80,33 @@ const guid = (size = 3) => {
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a hash code from a string
|
||||||
|
*
|
||||||
|
* From https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
||||||
|
*
|
||||||
|
* @param {String} str The string to hash
|
||||||
|
* @return {Number} A 32bit integer
|
||||||
|
*/
|
||||||
|
const hashCode = (str, seed = 0) => {
|
||||||
|
let h1 = 0xdeadbeef ^ seed,
|
||||||
|
h2 = 0x41c6ce57 ^ seed;
|
||||||
|
for (let i = 0, ch; i < str.length; i++) {
|
||||||
|
ch = str.charCodeAt(i);
|
||||||
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||||
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 =
|
||||||
|
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
|
||||||
|
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||||
|
h2 =
|
||||||
|
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
|
||||||
|
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||||
|
|
||||||
|
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns defaults to an option object passed to the function.
|
* Assigns defaults to an option object passed to the function.
|
||||||
*
|
*
|
||||||
|
@ -232,8 +259,8 @@ function cropCanvas(sourceCanvas, options = {}) {
|
||||||
|
|
||||||
bb.x = minx - options.border;
|
bb.x = minx - options.border;
|
||||||
bb.y = miny - options.border;
|
bb.y = miny - options.border;
|
||||||
bb.w = maxx - minx + 2 * options.border;
|
bb.w = maxx - minx + 1 + 2 * options.border;
|
||||||
bb.h = maxy - miny + 2 * options.border;
|
bb.h = maxy - miny + 1 + 2 * options.border;
|
||||||
|
|
||||||
if (maxx < 0) throw new NoContentError("Canvas has no content to crop");
|
if (maxx < 0) throw new NoContentError("Canvas has no content to crop");
|
||||||
|
|
||||||
|
|
189
js/prompt.js
Normal file
189
js/prompt.js
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
/**
|
||||||
|
* This file is for processing prompt/negative prompt and prompt style data
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Prompt Style Element
|
||||||
|
const styleSelectElement = createAutoComplete(
|
||||||
|
"Style",
|
||||||
|
document.getElementById("style-ac-mselect"),
|
||||||
|
{multiple: true}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Function to get styles from AUTOMATIC1111 webui
|
||||||
|
async function getStyles() {
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
styleSelectElement.options = data.map((style) => ({
|
||||||
|
name: style.name,
|
||||||
|
value: style.name,
|
||||||
|
title: `prompt: ${style.prompt}\nnegative: ${style.negative_prompt}`,
|
||||||
|
}));
|
||||||
|
styleSelectElement.onchange.on(({value}) => {
|
||||||
|
let selected = [];
|
||||||
|
if (value.find((v) => v === "None")) {
|
||||||
|
styleSelectElement.value = [];
|
||||||
|
} else {
|
||||||
|
selected = value;
|
||||||
|
}
|
||||||
|
stableDiffusionData.styles = selected;
|
||||||
|
localStorage.setItem("promptStyle", JSON.stringify(selected));
|
||||||
|
});
|
||||||
|
|
||||||
|
styleSelectElement.value = stored;
|
||||||
|
localStorage.setItem("promptStyle", JSON.stringify(stored));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[index] Failed to fetch prompt styles");
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Default configurations
|
||||||
|
const defaultPrompt =
|
||||||
|
"ocean floor scientific expedition, underwater wildlife";
|
||||||
|
const defaultNegativePrompt =
|
||||||
|
"people, person, humans, human, divers, diver, glitch, error, text, watermark, bad quality, blurry";
|
||||||
|
|
||||||
|
// Prompt Elements
|
||||||
|
const promptEl = document.getElementById("prompt");
|
||||||
|
const negativePromptEl = document.getElementById("negPrompt");
|
||||||
|
|
||||||
|
// Add prompt change handlers
|
||||||
|
promptEl.oninput = () => {
|
||||||
|
stableDiffusionData.prompt = promptEl.value;
|
||||||
|
promptEl.title = promptEl.value;
|
||||||
|
localStorage.setItem("prompt", stableDiffusionData.prompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
negativePromptEl.oninput = () => {
|
||||||
|
stableDiffusionData.negative_prompt = negativePromptEl.value;
|
||||||
|
negativePromptEl.title = negativePromptEl.value;
|
||||||
|
localStorage.setItem("neg_prompt", stableDiffusionData.negative_prompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load from local storage if set
|
||||||
|
const promptDefaultValue = localStorage.getItem("prompt") || defaultPrompt;
|
||||||
|
const negativePromptDefaultValue =
|
||||||
|
localStorage.getItem("neg_prompt") || defaultNegativePrompt;
|
||||||
|
|
||||||
|
promptEl.value = promptEl.title = promptDefaultValue;
|
||||||
|
negativePromptEl.value = negativePromptEl.title = negativePromptDefaultValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt History
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get history-related elements
|
||||||
|
const promptHistoryEl = document.getElementById("prompt-history");
|
||||||
|
|
||||||
|
// History
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
function syncPromptHistory() {
|
||||||
|
const historyCopy = Array.from(history);
|
||||||
|
historyCopy.reverse();
|
||||||
|
|
||||||
|
for (let i = 0; i < historyCopy.length; i++) {
|
||||||
|
const historyItem = historyCopy[i];
|
||||||
|
|
||||||
|
const id = `prompt-history-${historyItem.id}`;
|
||||||
|
if (promptHistoryEl.querySelector(`#${id}`)) break;
|
||||||
|
|
||||||
|
const historyEntry = document.createElement("div");
|
||||||
|
historyEntry.classList.add("entry");
|
||||||
|
historyEntry.id = id;
|
||||||
|
historyEntry.title = `prompt: ${historyItem.prompt}\nnegative: ${
|
||||||
|
historyItem.negative
|
||||||
|
}\nstyles: ${historyItem.styles.join(", ")}`;
|
||||||
|
|
||||||
|
// Compare with previous
|
||||||
|
const samePrompt =
|
||||||
|
i !== historyCopy.length - 1 &&
|
||||||
|
historyItem.prompt === historyCopy[i + 1].prompt;
|
||||||
|
const sameNegativePrompt =
|
||||||
|
i !== historyCopy.length - 1 &&
|
||||||
|
historyItem.negative === historyCopy[i + 1].negative;
|
||||||
|
const sameStyles =
|
||||||
|
i !== historyCopy.length - 1 &&
|
||||||
|
historyItem.styles.length === historyCopy[i + 1].styles.length &&
|
||||||
|
!historyItem.styles.some(
|
||||||
|
(v, index) => v !== historyCopy[i + 1].styles[index]
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = historyItem.prompt;
|
||||||
|
const negative = historyItem.negative;
|
||||||
|
const styles = historyItem.styles;
|
||||||
|
|
||||||
|
const promptBtn = document.createElement("button");
|
||||||
|
promptBtn.classList.add("prompt");
|
||||||
|
promptBtn.addEventListener("click", () => {
|
||||||
|
stableDiffusionData.prompt = prompt;
|
||||||
|
promptEl.title = prompt;
|
||||||
|
promptEl.value = prompt;
|
||||||
|
localStorage.setItem("prompt", prompt);
|
||||||
|
});
|
||||||
|
promptBtn.textContent = (samePrompt ? "= " : "") + prompt;
|
||||||
|
|
||||||
|
const negativeBtn = document.createElement("button");
|
||||||
|
negativeBtn.classList.add("negative");
|
||||||
|
negativeBtn.addEventListener("click", () => {
|
||||||
|
stableDiffusionData.negative_prompt = negative;
|
||||||
|
negativePromptEl.title = negative;
|
||||||
|
negativePromptEl.value = negative;
|
||||||
|
localStorage.setItem("neg_prompt", negative);
|
||||||
|
});
|
||||||
|
negativeBtn.textContent = (sameNegativePrompt ? "= " : "") + negative;
|
||||||
|
|
||||||
|
const stylesBtn = document.createElement("button");
|
||||||
|
stylesBtn.classList.add("styles");
|
||||||
|
stylesBtn.textContent = (sameStyles ? "= " : "") + styles.join(", ");
|
||||||
|
stylesBtn.addEventListener("click", () => {
|
||||||
|
styleSelectElement.value = styles;
|
||||||
|
});
|
||||||
|
|
||||||
|
historyEntry.appendChild(promptBtn);
|
||||||
|
historyEntry.appendChild(negativeBtn);
|
||||||
|
historyEntry.appendChild(stylesBtn);
|
||||||
|
|
||||||
|
promptHistoryEl.insertBefore(historyEntry, promptHistoryEl.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for dreaming to add to history
|
||||||
|
events.tool.dream.on((message) => {
|
||||||
|
const {event} = message;
|
||||||
|
if (event === "generate") {
|
||||||
|
const {prompt, negative_prompt, styles} = message.request;
|
||||||
|
const hash = hashCode(
|
||||||
|
`p: ${prompt}, n: ${negative_prompt}, s: ${JSON.stringify(styles)}`
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!history[history.length - 1] ||
|
||||||
|
history[history.length - 1].hash !== hash
|
||||||
|
)
|
||||||
|
history.push({
|
||||||
|
id: guid(),
|
||||||
|
hash,
|
||||||
|
prompt,
|
||||||
|
negative: negative_prompt,
|
||||||
|
styles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPromptHistory();
|
||||||
|
});
|
||||||
|
})();
|
|
@ -1,4 +1,6 @@
|
||||||
let blockNewImages = false;
|
let blockNewImages = false;
|
||||||
|
let generationQueue = [];
|
||||||
|
let generationAreas = new Set();
|
||||||
let generating = false;
|
let generating = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -118,20 +120,16 @@ const _generate = async (
|
||||||
bb,
|
bb,
|
||||||
drawEvery = 0.2 / request.n_iter
|
drawEvery = 0.2 / request.n_iter
|
||||||
) => {
|
) => {
|
||||||
const requestCopy = {...request};
|
events.tool.dream.emit({event: "generate", request});
|
||||||
|
|
||||||
// Images to select through
|
const requestCopy = JSON.parse(JSON.stringify(request));
|
||||||
let at = 0;
|
|
||||||
/** @type {Array<string|null>} */
|
|
||||||
const images = [null];
|
|
||||||
/** @type {HTMLDivElement} */
|
|
||||||
let imageSelectMenu = null;
|
|
||||||
|
|
||||||
// Layer for the images
|
// Block requests to identical areas
|
||||||
const layer = imageCollection.registerLayer(null, {
|
const areaid = `${bb.x}-${bb.y}-${bb.w}-${bb.h}`;
|
||||||
after: maskPaintLayer,
|
if (generationAreas.has(areaid)) return;
|
||||||
});
|
generationAreas.add(areaid);
|
||||||
|
|
||||||
|
// Makes an element in a location
|
||||||
const makeElement = (type, x, y) => {
|
const makeElement = (type, x, y) => {
|
||||||
const el = document.createElement(type);
|
const el = document.createElement(type);
|
||||||
el.style.position = "absolute";
|
el.style.position = "absolute";
|
||||||
|
@ -144,6 +142,74 @@ const _generate = async (
|
||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Await for queue
|
||||||
|
let cancelled = false;
|
||||||
|
const waitQueue = async () => {
|
||||||
|
const stopQueueMarchingAnts = march(bb, {style: "#AAF"});
|
||||||
|
|
||||||
|
// Add cancel Button
|
||||||
|
const cancelButton = makeElement("button", bb.x + bb.w - 100, bb.y + bb.h);
|
||||||
|
cancelButton.classList.add("dream-stop-btn");
|
||||||
|
cancelButton.textContent = "Cancel";
|
||||||
|
cancelButton.addEventListener("click", () => {
|
||||||
|
cancelled = true;
|
||||||
|
imageCollection.inputElement.removeChild(cancelButton);
|
||||||
|
stopQueueMarchingAnts();
|
||||||
|
});
|
||||||
|
imageCollection.inputElement.appendChild(cancelButton);
|
||||||
|
|
||||||
|
let qPromise = null;
|
||||||
|
let qResolve = null;
|
||||||
|
await new Promise((finish) => {
|
||||||
|
// Will be this request's (kind of) semaphore
|
||||||
|
qPromise = new Promise((r) => (qResolve = r));
|
||||||
|
generationQueue.push(qPromise);
|
||||||
|
|
||||||
|
// Wait for last generation to end
|
||||||
|
if (generationQueue.length > 1) {
|
||||||
|
(async () => {
|
||||||
|
await generationQueue[generationQueue.length - 2];
|
||||||
|
finish();
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
// If this is the first, just continue
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
imageCollection.inputElement.removeChild(cancelButton);
|
||||||
|
stopQueueMarchingAnts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {promise: qPromise, resolve: qResolve};
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextQueue = (queueEntry) => {
|
||||||
|
const generationIndex = generationQueue.findIndex(
|
||||||
|
(v) => v === queueEntry.promise
|
||||||
|
);
|
||||||
|
generationQueue.splice(generationIndex, 1);
|
||||||
|
queueEntry.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialQ = await waitQueue();
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
nextQueue(initialQ);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images to select through
|
||||||
|
let at = 0;
|
||||||
|
/** @type {Array<string|null>} */
|
||||||
|
const images = [null];
|
||||||
|
/** @type {HTMLDivElement} */
|
||||||
|
let imageSelectMenu = null;
|
||||||
|
// Layer for the images
|
||||||
|
const layer = imageCollection.registerLayer(null, {
|
||||||
|
after: maskPaintLayer,
|
||||||
|
});
|
||||||
|
|
||||||
const redraw = (url = images[at]) => {
|
const redraw = (url = images[at]) => {
|
||||||
if (url === null)
|
if (url === null)
|
||||||
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||||
|
@ -169,7 +235,7 @@ const _generate = async (
|
||||||
|
|
||||||
// Add Interrupt Button
|
// Add Interrupt Button
|
||||||
const interruptButton = makeElement("button", bb.x + bb.w - 100, bb.y + bb.h);
|
const interruptButton = makeElement("button", bb.x + bb.w - 100, bb.y + bb.h);
|
||||||
interruptButton.classList.add("dream-interrupt-btn");
|
interruptButton.classList.add("dream-stop-btn");
|
||||||
interruptButton.textContent = "Interrupt";
|
interruptButton.textContent = "Interrupt";
|
||||||
interruptButton.addEventListener("click", () => {
|
interruptButton.addEventListener("click", () => {
|
||||||
fetch(`${host}${url}interrupt`, {method: "POST"});
|
fetch(`${host}${url}interrupt`, {method: "POST"});
|
||||||
|
@ -253,6 +319,7 @@ const _generate = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeMore = async () => {
|
const makeMore = async () => {
|
||||||
|
const moreQ = await waitQueue();
|
||||||
try {
|
try {
|
||||||
stopProgress = _monitorProgress(bb);
|
stopProgress = _monitorProgress(bb);
|
||||||
interruptButton.disabled = false;
|
interruptButton.disabled = false;
|
||||||
|
@ -269,6 +336,8 @@ const _generate = async (
|
||||||
stopProgress();
|
stopProgress();
|
||||||
imageCollection.inputElement.removeChild(interruptButton);
|
imageCollection.inputElement.removeChild(interruptButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextQueue(moreQ);
|
||||||
};
|
};
|
||||||
|
|
||||||
const discardImg = async () => {
|
const discardImg = async () => {
|
||||||
|
@ -342,8 +411,9 @@ const _generate = async (
|
||||||
stopMarchingAnts();
|
stopMarchingAnts();
|
||||||
imageCollection.inputElement.removeChild(imageSelectMenu);
|
imageCollection.inputElement.removeChild(imageSelectMenu);
|
||||||
imageCollection.deleteLayer(layer);
|
imageCollection.deleteLayer(layer);
|
||||||
blockNewImages = false;
|
|
||||||
keyboard.listen.onkeyclick.clear(onarrow);
|
keyboard.listen.onkeyclick.clear(onarrow);
|
||||||
|
// Remove area from no-generate list
|
||||||
|
generationAreas.delete(areaid);
|
||||||
};
|
};
|
||||||
|
|
||||||
redraw();
|
redraw();
|
||||||
|
@ -414,6 +484,8 @@ const _generate = async (
|
||||||
saveImg();
|
saveImg();
|
||||||
});
|
});
|
||||||
imageSelectMenu.appendChild(savebtn);
|
imageSelectMenu.appendChild(savebtn);
|
||||||
|
|
||||||
|
nextQueue(initialQ);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -423,46 +495,75 @@ const _generate = async (
|
||||||
* @param {*} state
|
* @param {*} state
|
||||||
*/
|
*/
|
||||||
const dream_generate_callback = async (evn, state) => {
|
const dream_generate_callback = async (evn, state) => {
|
||||||
if (!blockNewImages) {
|
const bb = getBoundingBox(
|
||||||
const bb = getBoundingBox(
|
evn.x,
|
||||||
evn.x,
|
evn.y,
|
||||||
evn.y,
|
state.cursorSize,
|
||||||
state.cursorSize,
|
state.cursorSize,
|
||||||
state.cursorSize,
|
state.snapToGrid && basePixelCount
|
||||||
state.snapToGrid && basePixelCount
|
);
|
||||||
|
|
||||||
|
// Build request to the API
|
||||||
|
const request = {};
|
||||||
|
Object.assign(request, stableDiffusionData);
|
||||||
|
|
||||||
|
// Load prompt (maybe we should add some events so we don't have to do this)
|
||||||
|
request.prompt = document.getElementById("prompt").value;
|
||||||
|
request.negative_prompt = document.getElementById("negPrompt").value;
|
||||||
|
|
||||||
|
// Get visible pixels
|
||||||
|
const visibleCanvas = uil.getVisible(bb);
|
||||||
|
|
||||||
|
// Use txt2img if canvas is blank
|
||||||
|
if (isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) {
|
||||||
|
// Dream
|
||||||
|
_generate("txt2img", request, bb);
|
||||||
|
} else {
|
||||||
|
// Use img2img if not
|
||||||
|
|
||||||
|
// Temporary canvas for init image and mask generation
|
||||||
|
const auxCanvas = document.createElement("canvas");
|
||||||
|
auxCanvas.width = request.width;
|
||||||
|
auxCanvas.height = request.height;
|
||||||
|
const auxCtx = auxCanvas.getContext("2d");
|
||||||
|
|
||||||
|
auxCtx.fillStyle = "#000F";
|
||||||
|
|
||||||
|
// Get init image
|
||||||
|
auxCtx.fillRect(0, 0, request.width, request.height);
|
||||||
|
auxCtx.drawImage(
|
||||||
|
visibleCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
bb.w,
|
||||||
|
bb.h,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
request.width,
|
||||||
|
request.height
|
||||||
);
|
);
|
||||||
|
request.init_images = [auxCanvas.toDataURL()];
|
||||||
|
|
||||||
// Build request to the API
|
// Get mask image
|
||||||
const request = {};
|
auxCtx.fillStyle = "#000F";
|
||||||
Object.assign(request, stableDiffusionData);
|
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
|
||||||
|
auxCtx.globalCompositeOperation = "destination-in";
|
||||||
|
auxCtx.drawImage(
|
||||||
|
maskPaintCanvas,
|
||||||
|
bb.x,
|
||||||
|
bb.y,
|
||||||
|
bb.w,
|
||||||
|
bb.h,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
request.width,
|
||||||
|
request.height
|
||||||
|
);
|
||||||
|
|
||||||
// Load prompt (maybe we should add some events so we don't have to do this)
|
auxCtx.globalCompositeOperation = "destination-in";
|
||||||
request.prompt = document.getElementById("prompt").value;
|
|
||||||
request.negative_prompt = document.getElementById("negPrompt").value;
|
|
||||||
|
|
||||||
// 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(0, 0, bb.w, bb.h, visibleCanvas)) {
|
|
||||||
// Dream
|
|
||||||
_generate("txt2img", request, bb);
|
|
||||||
} else {
|
|
||||||
// Use img2img if not
|
|
||||||
|
|
||||||
// Temporary canvas for init image and mask generation
|
|
||||||
const auxCanvas = document.createElement("canvas");
|
|
||||||
auxCanvas.width = request.width;
|
|
||||||
auxCanvas.height = request.height;
|
|
||||||
const auxCtx = auxCanvas.getContext("2d");
|
|
||||||
|
|
||||||
auxCtx.fillStyle = "#000F";
|
|
||||||
|
|
||||||
// Get init image
|
|
||||||
auxCtx.fillRect(0, 0, request.width, request.height);
|
|
||||||
auxCtx.drawImage(
|
auxCtx.drawImage(
|
||||||
visibleCanvas,
|
visibleCanvas,
|
||||||
0,
|
0,
|
||||||
|
@ -474,82 +575,48 @@ const dream_generate_callback = async (evn, state) => {
|
||||||
request.width,
|
request.width,
|
||||||
request.height
|
request.height
|
||||||
);
|
);
|
||||||
request.init_images = [auxCanvas.toDataURL()];
|
} else {
|
||||||
|
auxCtx.globalCompositeOperation = "destination-in";
|
||||||
// Get mask image
|
auxCtx.drawImage(
|
||||||
auxCtx.fillStyle = "#000F";
|
visibleCanvas,
|
||||||
auxCtx.fillRect(0, 0, request.width, request.height);
|
0,
|
||||||
if (state.invertMask) {
|
0,
|
||||||
// overmasking by definition is entirely pointless with an inverted mask outpaint
|
bb.w,
|
||||||
// since it should explicitly avoid brushed masks too, we just won't even bother
|
bb.h,
|
||||||
auxCtx.globalCompositeOperation = "destination-in";
|
0,
|
||||||
auxCtx.drawImage(
|
0,
|
||||||
maskPaintCanvas,
|
request.width,
|
||||||
bb.x,
|
request.height
|
||||||
bb.y,
|
);
|
||||||
bb.w,
|
// here's where to overmask to avoid including the brushed mask
|
||||||
bb.h,
|
// 99% of my issues were from failing to set source-over for the overmask blotches
|
||||||
0,
|
if (state.overMaskPx > 0) {
|
||||||
0,
|
// transparent to white first
|
||||||
request.width,
|
auxCtx.globalCompositeOperation = "destination-atop";
|
||||||
request.height
|
auxCtx.fillStyle = "#FFFF";
|
||||||
);
|
auxCtx.fillRect(0, 0, request.width, request.height);
|
||||||
|
applyOvermask(auxCanvas, auxCtx, state.overMaskPx);
|
||||||
auxCtx.globalCompositeOperation = "destination-in";
|
|
||||||
auxCtx.drawImage(
|
|
||||||
visibleCanvas,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
bb.w,
|
|
||||||
bb.h,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
request.width,
|
|
||||||
request.height
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
auxCtx.globalCompositeOperation = "destination-in";
|
|
||||||
auxCtx.drawImage(
|
|
||||||
visibleCanvas,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
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, request.width, request.height);
|
|
||||||
applyOvermask(auxCanvas, auxCtx, state.overMaskPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
auxCtx.globalCompositeOperation = "destination-out"; // ???
|
|
||||||
auxCtx.drawImage(
|
|
||||||
maskPaintCanvas,
|
|
||||||
bb.x,
|
|
||||||
bb.y,
|
|
||||||
bb.w,
|
|
||||||
bb.h,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
request.width,
|
|
||||||
request.height
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
auxCtx.globalCompositeOperation = "destination-atop";
|
|
||||||
auxCtx.fillStyle = "#FFFF";
|
auxCtx.globalCompositeOperation = "destination-out"; // ???
|
||||||
auxCtx.fillRect(0, 0, request.width, request.height);
|
auxCtx.drawImage(
|
||||||
request.mask = auxCanvas.toDataURL();
|
maskPaintCanvas,
|
||||||
// Dream
|
bb.x,
|
||||||
_generate("img2img", request, bb);
|
bb.y,
|
||||||
|
bb.w,
|
||||||
|
bb.h,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
request.width,
|
||||||
|
request.height
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
auxCtx.globalCompositeOperation = "destination-atop";
|
||||||
|
auxCtx.fillStyle = "#FFFF";
|
||||||
|
auxCtx.fillRect(0, 0, request.width, request.height);
|
||||||
|
request.mask = auxCanvas.toDataURL();
|
||||||
|
// Dream
|
||||||
|
_generate("img2img", request, bb);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const dream_erase_callback = (evn, state) => {
|
const dream_erase_callback = (evn, state) => {
|
||||||
|
@ -606,140 +673,135 @@ function applyOvermask(canvas, ctx, px) {
|
||||||
* Image to Image
|
* Image to Image
|
||||||
*/
|
*/
|
||||||
const dream_img2img_callback = (evn, state) => {
|
const dream_img2img_callback = (evn, state) => {
|
||||||
if (!blockNewImages) {
|
const bb = getBoundingBox(
|
||||||
const bb = getBoundingBox(
|
evn.x,
|
||||||
evn.x,
|
evn.y,
|
||||||
evn.y,
|
state.cursorSize,
|
||||||
state.cursorSize,
|
state.cursorSize,
|
||||||
state.cursorSize,
|
state.snapToGrid && basePixelCount
|
||||||
state.snapToGrid && basePixelCount
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// Get visible pixels
|
// Get visible pixels
|
||||||
const visibleCanvas = uil.getVisible(bb);
|
const visibleCanvas = uil.getVisible(bb);
|
||||||
|
|
||||||
// Do nothing if no image exists
|
// Do nothing if no image exists
|
||||||
if (isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) return;
|
if (isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) return;
|
||||||
|
|
||||||
// Build request to the API
|
// Build request to the API
|
||||||
const request = {};
|
const request = {};
|
||||||
Object.assign(request, stableDiffusionData);
|
Object.assign(request, stableDiffusionData);
|
||||||
|
|
||||||
request.denoising_strength = state.denoisingStrength;
|
request.denoising_strength = state.denoisingStrength;
|
||||||
request.inpainting_fill = 1; // For img2img use original
|
request.inpainting_fill = 1; // For img2img use original
|
||||||
|
|
||||||
// Load prompt (maybe we should add some events so we don't have to do this)
|
// Load prompt (maybe we should add some events so we don't have to do this)
|
||||||
request.prompt = document.getElementById("prompt").value;
|
request.prompt = document.getElementById("prompt").value;
|
||||||
request.negative_prompt = document.getElementById("negPrompt").value;
|
request.negative_prompt = document.getElementById("negPrompt").value;
|
||||||
|
|
||||||
// Don't allow another image until is finished
|
// Use img2img
|
||||||
blockNewImages = true;
|
|
||||||
|
|
||||||
// Use img2img
|
// Temporary canvas for init image and mask generation
|
||||||
|
const auxCanvas = document.createElement("canvas");
|
||||||
|
auxCanvas.width = request.width;
|
||||||
|
auxCanvas.height = request.height;
|
||||||
|
const auxCtx = auxCanvas.getContext("2d");
|
||||||
|
|
||||||
// Temporary canvas for init image and mask generation
|
auxCtx.fillStyle = "#000F";
|
||||||
const auxCanvas = document.createElement("canvas");
|
|
||||||
auxCanvas.width = request.width;
|
|
||||||
auxCanvas.height = request.height;
|
|
||||||
const auxCtx = auxCanvas.getContext("2d");
|
|
||||||
|
|
||||||
|
// Get init image
|
||||||
|
auxCtx.fillRect(0, 0, request.width, request.height);
|
||||||
|
auxCtx.drawImage(
|
||||||
|
visibleCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
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, request.width, request.height);
|
||||||
|
auxCtx.globalCompositeOperation = "destination-out";
|
||||||
|
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, request.width, request.height);
|
||||||
|
|
||||||
|
// Border Mask
|
||||||
|
if (state.keepBorderSize > 0) {
|
||||||
|
auxCtx.globalCompositeOperation = "source-over";
|
||||||
auxCtx.fillStyle = "#000F";
|
auxCtx.fillStyle = "#000F";
|
||||||
|
if (state.gradient) {
|
||||||
// Get init image
|
const lg = auxCtx.createLinearGradient(0, 0, state.keepBorderSize, 0);
|
||||||
auxCtx.fillRect(0, 0, request.width, request.height);
|
lg.addColorStop(0, "#000F");
|
||||||
auxCtx.drawImage(
|
lg.addColorStop(1, "#0000");
|
||||||
visibleCanvas,
|
auxCtx.fillStyle = lg;
|
||||||
0,
|
|
||||||
0,
|
|
||||||
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, request.width, request.height);
|
|
||||||
auxCtx.globalCompositeOperation = "destination-out";
|
|
||||||
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, request.width, request.height);
|
|
||||||
|
|
||||||
// Border Mask
|
|
||||||
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,
|
|
||||||
request.width,
|
|
||||||
state.keepBorderSize
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
auxCtx.fillRect(0, 0, state.keepBorderSize, request.height);
|
||||||
request.mask = auxCanvas.toDataURL();
|
if (state.gradient) {
|
||||||
request.inpaint_full_res = state.fullResolution;
|
const tg = auxCtx.createLinearGradient(0, 0, 0, state.keepBorderSize);
|
||||||
|
tg.addColorStop(0, "#000F");
|
||||||
// Dream
|
tg.addColorStop(1, "#0000");
|
||||||
_generate("img2img", request, bb);
|
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,
|
||||||
|
request.width,
|
||||||
|
state.keepBorderSize
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.mask = auxCanvas.toDataURL();
|
||||||
|
request.inpaint_full_res = state.fullResolution;
|
||||||
|
|
||||||
|
// Dream
|
||||||
|
_generate("img2img", request, bb);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -92,34 +92,48 @@ const stampTool = () =>
|
||||||
if (state.loaded) state.movecb(state.lastMouseMove);
|
if (state.loaded) state.movecb(state.lastMouseMove);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Open IndexedDB connection
|
||||||
|
const IDBOpenRequest = window.indexedDB.open("stamp", 1);
|
||||||
|
|
||||||
// Synchronizes resources array with the DOM and Local Storage
|
// Synchronizes resources array with the DOM and Local Storage
|
||||||
const syncResources = () => {
|
const syncResources = () => {
|
||||||
// Saves to local storage
|
// Saves to IndexedDB
|
||||||
|
/** @type {IDBDatabase} */
|
||||||
|
const db = state.stampDB;
|
||||||
|
const resources = db
|
||||||
|
.transaction("resources", "readwrite")
|
||||||
|
.objectStore("resources");
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
const FetchKeysQuery = resources.getAllKeys();
|
||||||
"tools.stamp.resources",
|
FetchKeysQuery.onsuccess = () => {
|
||||||
JSON.stringify(
|
const keys = FetchKeysQuery.result;
|
||||||
state.resources
|
keys.forEach((key) => {
|
||||||
.filter((resource) => !resource.temporary)
|
if (!state.resources.find((resource) => resource.id === key))
|
||||||
.map((resource) => {
|
resources.delete(key);
|
||||||
const canvas = document.createElement("canvas");
|
});
|
||||||
canvas.width = resource.image.width;
|
};
|
||||||
canvas.height = resource.image.height;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
state.resources
|
||||||
ctx.drawImage(resource.image, 0, 0);
|
.filter((resource) => !resource.temporary && resource.dirty)
|
||||||
|
.forEach((resource) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = resource.image.width;
|
||||||
|
canvas.height = resource.image.height;
|
||||||
|
|
||||||
return {
|
const ctx = canvas.getContext("2d");
|
||||||
id: resource.id,
|
ctx.drawImage(resource.image, 0, 0);
|
||||||
name: resource.name,
|
|
||||||
src: canvas.toDataURL(),
|
resources.put({
|
||||||
};
|
id: resource.id,
|
||||||
})
|
name: resource.name,
|
||||||
)
|
src: canvas.toDataURL(),
|
||||||
);
|
});
|
||||||
|
|
||||||
|
resource.dirty = false;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[stamp] Failed to synchronize resources with local storage"
|
"[stamp] Failed to synchronize resources with IndexedDB"
|
||||||
);
|
);
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
|
@ -143,6 +157,7 @@ const stampTool = () =>
|
||||||
resourceTitle.style.pointerEvents = "none";
|
resourceTitle.style.pointerEvents = "none";
|
||||||
resourceTitle.addEventListener("change", () => {
|
resourceTitle.addEventListener("change", () => {
|
||||||
resource.name = resourceTitle.value;
|
resource.name = resourceTitle.value;
|
||||||
|
resource.dirty = true;
|
||||||
resourceTitle.title = resourceTitle.value;
|
resourceTitle.title = resourceTitle.value;
|
||||||
|
|
||||||
syncResources();
|
syncResources();
|
||||||
|
@ -181,6 +196,7 @@ const stampTool = () =>
|
||||||
saveButton.addEventListener(
|
saveButton.addEventListener(
|
||||||
"click",
|
"click",
|
||||||
(evn) => {
|
(evn) => {
|
||||||
|
evn.stopPropagation();
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = resource.image.width;
|
canvas.width = resource.image.width;
|
||||||
canvas.height = resource.image.height;
|
canvas.height = resource.image.height;
|
||||||
|
@ -245,6 +261,7 @@ const stampTool = () =>
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
image,
|
image,
|
||||||
|
dirty: true,
|
||||||
temporary,
|
temporary,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -389,7 +406,6 @@ const stampTool = () =>
|
||||||
image.onload = () => state.addResource(file.name, image, false);
|
image.onload = () => state.addResource(file.name, image, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadButton.value = null;
|
uploadButton.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -440,29 +456,65 @@ const stampTool = () =>
|
||||||
state.ctxmenu.resourceManager = resourceManager;
|
state.ctxmenu.resourceManager = resourceManager;
|
||||||
state.ctxmenu.resourceList = resourceList;
|
state.ctxmenu.resourceList = resourceList;
|
||||||
|
|
||||||
// Performs resource fetch from local storage
|
// Performs resource fetch from IndexedDB
|
||||||
(async () => {
|
|
||||||
const storageResources = localStorage.getItem(
|
IDBOpenRequest.onerror = (e) => {
|
||||||
"tools.stamp.resources"
|
console.warn("[stamp] Failed to connect to IndexedDB");
|
||||||
);
|
console.warn(e);
|
||||||
if (storageResources) {
|
};
|
||||||
const parsed = JSON.parse(storageResources);
|
|
||||||
|
IDBOpenRequest.onupgradeneeded = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
|
||||||
|
console.debug(`[stamp] Setting up database version ${db.version}`);
|
||||||
|
|
||||||
|
const resourcesStore = db.createObjectStore("resources", {
|
||||||
|
keyPath: "id",
|
||||||
|
});
|
||||||
|
resourcesStore.createIndex("name", "name", {unique: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
IDBOpenRequest.onsuccess = async (e) => {
|
||||||
|
console.debug("[stamp] Connected to IndexedDB");
|
||||||
|
|
||||||
|
state.stampDB = e.target.result;
|
||||||
|
|
||||||
|
state.stampDB.onerror = (evn) => {
|
||||||
|
console.warn(`[stamp] Database Error:`);
|
||||||
|
console.warn(evn.target.errorCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {IDBDatabase} */
|
||||||
|
const db = state.stampDB;
|
||||||
|
/** @type {IDBRequest<{id: string, name: string, src: string}[]>} */
|
||||||
|
const FetchAllTransaction = db
|
||||||
|
.transaction("resources")
|
||||||
|
.objectStore("resources")
|
||||||
|
.getAll();
|
||||||
|
|
||||||
|
FetchAllTransaction.onsuccess = async () => {
|
||||||
|
const data = FetchAllTransaction.result;
|
||||||
|
|
||||||
state.resources.push(
|
state.resources.push(
|
||||||
...(await Promise.all(
|
...(await Promise.all(
|
||||||
parsed.map((resource) => {
|
data.map((resource) => {
|
||||||
const image = document.createElement("img");
|
const image = document.createElement("img");
|
||||||
image.src = resource.src;
|
image.src = resource.src;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
image.onload = () =>
|
image.onload = () =>
|
||||||
resolve({id: resource.id, name: resource.name, image});
|
resolve({
|
||||||
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
image,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
syncResources();
|
syncResources();
|
||||||
}
|
};
|
||||||
})();
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
populateContextMenu: (menu, state) => {
|
populateContextMenu: (menu, state) => {
|
||||||
|
|
6
res/icons/history.svg
Normal file
6
res/icons/history.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 3v5h5"></path>
|
||||||
|
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"></path>
|
||||||
|
<path d="M12 7v5l4 2"></path>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 299 B |
7
res/icons/library.svg
Normal file
7
res/icons/library.svg
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m16 6 4 14"></path>
|
||||||
|
<path d="M12 6v14"></path>
|
||||||
|
<path d="M8 8v12"></path>
|
||||||
|
<path d="M4 4v16"></path>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 305 B |
5
res/icons/minus-square.svg
Normal file
5
res/icons/minus-square.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 301 B |
4
res/icons/minus.svg
Normal file
4
res/icons/minus.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 236 B |
6
res/icons/plus-square.svg
Normal file
6
res/icons/plus-square.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 348 B |
5
res/icons/plus.svg
Normal file
5
res/icons/plus.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
|
||||||
|
</svg>
|
After Width: | Height: | Size: 283 B |
Loading…
Reference in a new issue