diff --git a/css/fonts.css b/css/fonts.css new file mode 100644 index 0000000..afdc8a7 --- /dev/null +++ b/css/fonts.css @@ -0,0 +1,4 @@ +@font-face { + font-family: "Open Sans", sans-serif; + src: url("/res/fonts/OpenSans.ttf") format("truetype"); +} diff --git a/css/index.css b/css/index.css index abc7604..f8c634e 100644 --- a/css/index.css +++ b/css/index.css @@ -33,10 +33,37 @@ body { border: none; text-align: center; outline: none; - font-size: 15px; - padding: 5px; + padding: 0px; +} + +.collapsible { + background-color: var(--c-primary); + + margin-bottom: 2px; margin-top: 5px; - margin-bottom: 5px; + + transition-duration: 50ms; +} + +.collapsible::before { + content: ""; + display: block; + + position: absolute; + + width: 21px; + height: 21px; + + background-color: var(--c-text); + mask-image: url("/res/icons/chevron-up.svg"); + -webkit-mask-image: url("/res/icons/chevron-up.svg"); + mask-size: contain; + -webkit-mask-size: contain; + rotate: 90deg; +} + +.collapsible.active::before { + rotate: 180deg; } .display-none { @@ -44,7 +71,11 @@ body { } .collapsible:hover { - background-color: #777; + background-color: var(--c-hover); +} + +.collapsible:active { + filter: brightness(110%); } .content { @@ -259,7 +290,7 @@ body { color: #3f1f1f; } -.host-field-wrapper .connection-status.cors-issue { +.host-field-wrapper .connection-status.webui-issue { background-color: #dddd49; color: #3f3f1f; } diff --git a/css/ui/generic.css b/css/ui/generic.css index 7be86b1..df694ec 100644 --- a/css/ui/generic.css +++ b/css/ui/generic.css @@ -145,6 +145,24 @@ div.autocomplete > .autocomplete-list > .autocomplete-option:hover { background-color: #dddf; } +div.autocomplete > .autocomplete-list > .autocomplete-option.selected::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; +} + /* Select Input */ select > option:checked::after { content: ""; @@ -218,3 +236,112 @@ select > option:checked::after { mask-repeat: no-repeat; background-color: var(--c-text); } + +/** + * Generic list + */ + +.list { + height: 200px; + + overflow-y: auto; + + background-color: var(--c-primary); + + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.list > *:first-child { + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.list .list-item { + display: flex; + align-items: center; + justify-content: space-between; + + height: 25px; + padding-left: 5px; + padding-right: 5px; + + cursor: pointer; + + color: var(--c-text); + + transition-duration: 50ms; +} + +.list .list-item.active { + background-color: var(--c-active); +} +.list .list-item.active:hover, +.list .list-item:hover { + background-color: var(--c-hover); +} +.list .list-item.active:active, +.list .list-item:active { + background-color: var(--c-hover); + filter: brightness(120%); +} + +.list .list-item > .title { + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + background-color: transparent; + + border: 0; + color: var(--c-text); +} + +.list .list-item > .actions { + display: flex; + align-self: stretch; +} + +.list .actions > button { + display: flex; + align-items: stretch; + + padding: 0; + + width: 25px; + aspect-ratio: 1; + + background-color: transparent; + border: 0; + cursor: pointer; +} + +.list .list-item > .actions > *:hover > * { + margin: 2px; +} + +.list .actions > button > *:first-child { + flex: 1; + margin: 3px; + + -webkit-mask-size: contain; + mask-size: contain; + background-color: var(--c-text); +} + +/* Generic buttons */ +.list .actions > .delete-btn > *:first-child { + -webkit-mask-image: url("/res/icons/trash.svg"); + mask-image: url("/res/icons/trash.svg"); +} + +.list .actions > .rename-btn > *:first-child { + -webkit-mask-image: url("/res/icons/edit.svg"); + mask-image: url("/res/icons/edit.svg"); +} + +.list .actions > .download-btn > *:first-child { + -webkit-mask-image: url("/res/icons/download.svg"); + mask-image: url("/res/icons/download.svg"); +} diff --git a/css/ui/tool/stamp.css b/css/ui/tool/stamp.css index a685abb..4686e19 100644 --- a/css/ui/tool/stamp.css +++ b/css/ui/tool/stamp.css @@ -28,8 +28,6 @@ .resource-manager > .resource-list { height: 200px; - background-color: #ffffff66; - border-top-left-radius: 5px; border-top-right-radius: 5px; @@ -37,80 +35,6 @@ overflow-x: hidden; } -.resource-manager > .resource-list > * { - display: flex; - justify-content: space-between; - align-items: center; - - cursor: pointer; - 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: #fff8; -} -.resource-manager > .resource-list > .selected { - background-color: #fff6; -} - -.resource-manager > .resource-list.dragging { - background-color: #ffffff88; - transition-duration: 0.3s; -} - .resource-manager > .upload-button { display: block; width: 100%; diff --git a/index.html b/index.html index 79ef8dd..1c9b8c9 100644 --- a/index.html +++ b/index.html @@ -62,11 +62,7 @@ - +
diff --git a/js/index.js b/js/index.js index 7a3049d..63340c1 100644 --- a/js/index.js +++ b/js/index.js @@ -176,7 +176,7 @@ async function testHostConnection() { online: () => { connectionIndicator.classList.add("online"); connectionIndicator.classList.remove( - "cors-issue", + "webui-issue", "offline", "before", "server-error" @@ -191,7 +191,7 @@ async function testHostConnection() { "online", "offline", "before", - "cors-issue" + "webui-issue" ); connectionIndicatorText.textContent = "Error"; connectionIndicator.title = @@ -199,7 +199,7 @@ async function testHostConnection() { connectionStatus = false; }, corsissue: () => { - connectionIndicator.classList.add("cors-issue"); + connectionIndicator.classList.add("webui-issue"); connectionIndicator.classList.remove( "online", "offline", @@ -211,10 +211,23 @@ async function testHostConnection() { "Server is online, but CORS is blocking our requests"; connectionStatus = false; }, + apiissue: () => { + connectionIndicator.classList.add("webui-issue"); + connectionIndicator.classList.remove( + "online", + "offline", + "before", + "server-error" + ); + connectionIndicatorText.textContent = "API"; + connectionIndicator.title = + "Server is online, but the API seems to be disabled"; + connectionStatus = false; + }, offline: () => { connectionIndicator.classList.add("offline"); connectionIndicator.classList.remove( - "cors-issue", + "webui-issue", "online", "before", "server-error" @@ -227,7 +240,7 @@ async function testHostConnection() { before: () => { connectionIndicator.classList.add("before"); connectionIndicator.classList.remove( - "cors-issue", + "webui-issue", "online", "offline", "server-error" @@ -254,27 +267,37 @@ async function testHostConnection() { var url = document.getElementById("host").value + "/startup-events"; // Attempt normal request try { - /** @type {Response} */ - const response = await fetch(url, { - signal: AbortSignal.timeout(5000), - }); - - if (response.status === 200) { - setConnectionStatus("online"); - // Load data as soon as connection is first stablished - if (firstTimeOnline) { - getConfig(); - getStyles(); - getSamplers(); - getUpscalers(); - getModels(); - firstTimeOnline = false; + // Check if API is available + const response = await fetch( + document.getElementById("host").value + "/sdapi/v1/options" + ); + switch (response.status) { + case 200: { + setConnectionStatus("online"); + // Load data as soon as connection is first stablished + if (firstTimeOnline) { + getConfig(); + getStyles(); + getSamplers(); + getUpscalers(); + getModels(); + firstTimeOnline = false; + } + break; + } + case 404: { + setConnectionStatus("apiissue"); + const message = `The host is online, but the API seems to be disabled. Have you run the webui with the flag --api?`; + console.error(message); + if (notify) alert(message); + break; + } + default: { + setConnectionStatus("offline"); + const message = `The connection with the host returned an error: ${response.status} - ${response.statusText}`; + console.error(message); + if (notify) alert(message); } - } else { - setConnectionStatus("error"); - const message = `Server responded with ${response.status} - ${response.statusText}. Try running the webui with the flag '--api'`; - console.error(message); - if (notify) alert(message); } } catch (e) { try { @@ -424,6 +447,12 @@ const makeSlider = ( }); }; +const styleAutoComplete = createAutoComplete( + "Style", + document.getElementById("style-ac-mselect"), + {multiple: true} +); + const modelAutoComplete = createAutoComplete( "Model", document.getElementById("models-ac-select") @@ -774,28 +803,23 @@ async function getStyles() { 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); + 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"); diff --git a/js/lib/ui.js b/js/lib/ui.js index bbb3f3a..a835567 100644 --- a/js/lib/ui.js +++ b/js/lib/ui.js @@ -199,11 +199,13 @@ function createSlider(name, wrapper, options = {}) { * @param {string} name Name of the AutoComplete Select Element * @param {HTMLDivElement} wrapper The div element that will wrap the input elements * @param {object} options Extra options - * @param {{name: string, value: string}} options.options Options to add to the selector + * @param {boolean} options.multiple Whether multiple options can be selected + * @param {{name: string, value: string}[]} options.options Options to add to the selector * @returns {AutoCompleteElement} */ function createAutoComplete(name, wrapper, options = {}) { defaultOpt(options, { + multiple: false, options: [], }); @@ -226,27 +228,46 @@ function createAutoComplete(name, wrapper, options = {}) { const acobj = { name, wrapper, - _title: null, - _value: null, + _selectedOptions: new Set(), _options: [], /** @type {Observer<{name:string, value: string}>} */ onchange: new Observer(), get value() { - return this._value; + const v = this._selectedOptions.map((opt) => opt.value); + return options.multiple ? v : v[0]; }, - set value(val) { - const opt = this.options.find((option) => option.value === val); + set value(values) { + this._selectedOptions.clear(); - if (!opt) return; + for (const val of options.multiple ? values : [values]) { + const opt = this.options.find((option) => option.value === val); - this._title = opt.name; - this._value = opt.value; - inputEl.value = opt.name; - inputEl.title = opt.name; + if (!opt) continue; // Ignore invalid options - this.onchange.emit({name: opt.name, value: opt.value}); + this._selectedOptions.add(opt); + } + + this._sync(); + }, + + _sync() { + const val = Array.from(this._selectedOptions).map((opt) => opt.value); + const name = Array.from(this._selectedOptions).map((opt) => opt.name); + + for (const opt of this._options) { + if (acobj._selectedOptions.has(opt)) + opt.optionElement.classList.add("selected"); + else opt.optionElement.classList.remove("selected"); + } + + updateInputField(); + + this.onchange.emit({ + name: options.multiple ? name : name[0], + value: options.multiple ? val : val[0], + }); }, get options() { @@ -261,15 +282,17 @@ function createAutoComplete(name, wrapper, options = {}) { // Add options val.forEach((opt) => { - const {name, value} = opt; - const option = {name, value}; + const {name, value, title} = opt; const optionEl = document.createElement("option"); optionEl.classList.add("autocomplete-option"); - optionEl.title = option.name; - optionEl.addEventListener("click", () => select(option)); + optionEl.title = title || name; - this._options.push({name, value, optionElement: optionEl}); + const option = {name, value, optionElement: optionEl}; + + this._options.push(option); + + optionEl.addEventListener("click", () => select(option)); autocompleteEl.appendChild(optionEl); }); @@ -278,6 +301,15 @@ function createAutoComplete(name, wrapper, options = {}) { }, }; + function updateInputField() { + inputEl.value = Array.from(acobj._selectedOptions) + .map((o) => o.name) + .join(", "); + inputEl.title = Array.from(acobj._selectedOptions) + .map((o) => o.name) + .join(", "); + } + function updateOptions() { const text = inputEl.value.toLowerCase().trim(); @@ -310,15 +342,26 @@ function createAutoComplete(name, wrapper, options = {}) { }); } - function select(options) { + function select(opt) { ontext = false; - onlist = false; + if (!options.multiple) { + onlist = false; + acobj._selectedOptions.clear(); + autocompleteEl.classList.add("display-none"); + for (const child of autocompleteEl.children) { + child.classList.remove("selected"); + } + } - acobj._title = options.name; - inputEl.value = options.name; - acobj.value = options.value; + if (options.multiple && acobj._selectedOptions.has(opt)) { + acobj._selectedOptions.delete(opt); + opt.optionElement.classList.remove("selected"); + } else { + acobj._selectedOptions.add(opt); + opt.optionElement.classList.add("selected"); + } - autocompleteEl.classList.add("display-none"); + acobj._sync(); } inputEl.addEventListener("focus", () => { @@ -331,9 +374,7 @@ function createAutoComplete(name, wrapper, options = {}) { ontext = false; if (!onlist && !ontext) { - inputEl.value = ""; - updateOptions(); - inputEl.value = acobj._title; + updateInputField(); autocompleteEl.classList.add("display-none"); } @@ -347,9 +388,7 @@ function createAutoComplete(name, wrapper, options = {}) { onlist = false; if (!onlist && !ontext) { - inputEl.value = ""; - updateOptions(); - inputEl.value = acobj._title; + updateInputField(); autocompleteEl.classList.add("display-none"); } diff --git a/js/ui/tool/dream.js b/js/ui/tool/dream.js index a2893d5..ec9719a 100644 --- a/js/ui/tool/dream.js +++ b/js/ui/tool/dream.js @@ -208,7 +208,7 @@ const _generate = async ( at = 1; } catch (e) { alert( - `Error generating images. Please try again or see consolde for more details` + `Error generating images. Please try again or see console for more details` ); console.warn(`[dream] Error generating images:`); console.warn(e); @@ -235,6 +235,8 @@ const _generate = async ( }; const applyImg = async () => { + if (!images[at]) return; + const img = new Image(); // load the image data after defining the closure img.src = "data:image/png;base64," + images[at]; @@ -259,7 +261,7 @@ const _generate = async ( imageindextxt.textContent = `${at}/${images.length - 1}`; } catch (e) { alert( - `Error generating images. Please try again or see consolde for more details` + `Error generating images. Please try again or see console for more details` ); console.warn(`[dream] Error generating images:`); console.warn(e); @@ -273,6 +275,25 @@ const _generate = async ( clean(); }; + const saveImg = async () => { + if (!images[at]) return; + + const img = new Image(); + // load the image data after defining the closure + img.src = "data:image/png;base64," + images[at]; + img.addEventListener("load", () => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext("2d").drawImage(img, 0, 0); + + downloadCanvas({ + canvas, + filename: `openOutpaint - dream - ${request.prompt} - ${at}.png`, + }); + }); + }; + // Listen for keyboard arrows const onarrow = (evn) => { switch (evn.target.tagName.toLowerCase()) { @@ -385,6 +406,14 @@ const _generate = async ( }); }); imageSelectMenu.appendChild(resourcebtn); + + const savebtn = document.createElement("button"); + savebtn.textContent = "S"; + savebtn.title = "Download image to computer"; + savebtn.addEventListener("click", async () => { + saveImg(); + }); + imageSelectMenu.appendChild(savebtn); }; /** diff --git a/js/ui/tool/stamp.js b/js/ui/tool/stamp.js index 8cf3bc7..8be2e83 100644 --- a/js/ui/tool/stamp.js +++ b/js/ui/tool/stamp.js @@ -44,7 +44,7 @@ const stampTool = () => // Deselect state.selected = null; Array.from(state.ctxmenu.resourceList.children).forEach((child) => { - child.classList.remove("selected"); + child.classList.remove("active"); }); ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height); @@ -71,20 +71,20 @@ const stampTool = () => const resourceWrapper = resource && resource.dom.wrapper; const wasSelected = - resourceWrapper && resourceWrapper.classList.contains("selected"); + resourceWrapper && resourceWrapper.classList.contains("active"); Array.from(state.ctxmenu.resourceList.children).forEach((child) => { - child.classList.remove("selected"); + child.classList.remove("active"); }); // Select if (!wasSelected) { - resourceWrapper && resourceWrapper.classList.add("selected"); + resourceWrapper && resourceWrapper.classList.add("active"); state.selected = resource; } // If already selected, clear selection else { - resourceWrapper.classList.remove("selected"); + resourceWrapper.classList.remove("active"); state.selected = null; } @@ -136,16 +136,35 @@ const stampTool = () => ); const resourceWrapper = document.createElement("div"); resourceWrapper.id = `resource-${resource.id}`; - resourceWrapper.classList.add("resource"); - const resourceTitle = document.createElement("span"); - resourceTitle.textContent = resource.name; - resourceTitle.classList.add("resource-title"); + resourceWrapper.classList.add("resource", "list-item"); + const resourceTitle = document.createElement("input"); + resourceTitle.value = resource.name; + resourceTitle.title = resource.name; + resourceTitle.style.pointerEvents = "none"; + resourceTitle.addEventListener("change", () => { + resource.name = resourceTitle.value; + resourceTitle.title = resourceTitle.value; + + syncResources(); + }); + + resourceTitle.addEventListener("blur", () => { + resourceTitle.style.pointerEvents = "none"; + }); + resourceTitle.classList.add("resource-title", "title"); + resourceWrapper.appendChild(resourceTitle); resourceWrapper.addEventListener("click", () => state.selectResource(resource) ); + resourceWrapper.addEventListener("dblclick", () => { + resourceTitle.style.pointerEvents = "auto"; + resourceTitle.focus(); + resourceTitle.select(); + }); + resourceWrapper.addEventListener("mouseover", () => { state.ctxmenu.previewPane.style.display = "block"; state.ctxmenu.previewPane.style.backgroundImage = `url(${resource.image.src})`; @@ -158,24 +177,25 @@ const stampTool = () => const actionArray = document.createElement("div"); actionArray.classList.add("actions"); - const renameButton = document.createElement("button"); - renameButton.addEventListener( + const saveButton = document.createElement("button"); + saveButton.addEventListener( "click", (evn) => { - evn.stopPropagation(); - const name = prompt("Rename your resource:", resource.name); - if (name) { - resource.name = name; - resourceTitle.textContent = name; + const canvas = document.createElement("canvas"); + canvas.width = resource.image.width; + canvas.height = resource.image.height; + canvas.getContext("2d").drawImage(resource.image, 0, 0); - syncResources(); - } + downloadCanvas({ + canvas, + filename: `openOutpaint - resource '${resource.name}'.png`, + }); }, {passive: false} ); - renameButton.title = "Rename Resource"; - renameButton.appendChild(document.createElement("div")); - renameButton.classList.add("rename-btn"); + saveButton.title = "Download Resource"; + saveButton.appendChild(document.createElement("div")); + saveButton.classList.add("download-btn"); const trashButton = document.createElement("button"); trashButton.addEventListener( @@ -191,7 +211,7 @@ const stampTool = () => trashButton.appendChild(document.createElement("div")); trashButton.classList.add("delete-btn"); - actionArray.appendChild(renameButton); + actionArray.appendChild(saveButton); actionArray.appendChild(trashButton); resourceWrapper.appendChild(actionArray); state.ctxmenu.resourceList.appendChild(resourceWrapper); @@ -340,7 +360,7 @@ const stampTool = () => const resourceManager = document.createElement("div"); resourceManager.classList.add("resource-manager"); const resourceList = document.createElement("div"); - resourceList.classList.add("resource-list"); + resourceList.classList.add("list"); const previewPane = document.createElement("div"); previewPane.classList.add("preview-pane"); diff --git a/openOutpaint.bat b/openOutpaint.bat index fcd0061..99e265f 100644 --- a/openOutpaint.bat +++ b/openOutpaint.bat @@ -1,2 +1,2 @@ @echo off -python -m http.server 3456 \ No newline at end of file +python -m http.server -b 0.0.0.0 3456 \ No newline at end of file diff --git a/res/fonts/OpenSans.ttf b/res/fonts/OpenSans.ttf new file mode 100644 index 0000000..db43334 Binary files /dev/null and b/res/fonts/OpenSans.ttf differ diff --git a/res/icons/download.svg b/res/icons/download.svg new file mode 100644 index 0000000..e04900e --- /dev/null +++ b/res/icons/download.svg @@ -0,0 +1,7 @@ + \ No newline at end of file