mirror of
https://github.com/easydiffusion/easydiffusion.git
synced 2024-12-24 16:08:55 +01:00
3ea74af76d
By splitting the confirmation function into two halves, the closure was lost
926 lines
28 KiB
JavaScript
926 lines
28 KiB
JavaScript
"use strict"
|
|
|
|
// https://gomakethings.com/finding-the-next-and-previous-sibling-elements-that-match-a-selector-with-vanilla-js/
|
|
function getNextSibling(elem, selector) {
|
|
// Get the next sibling element
|
|
let sibling = elem.nextElementSibling
|
|
|
|
// If there's no selector, return the first sibling
|
|
if (!selector) {
|
|
return sibling
|
|
}
|
|
|
|
// If the sibling matches our selector, use it
|
|
// If not, jump to the next sibling and continue the loop
|
|
while (sibling) {
|
|
if (sibling.matches(selector)) {
|
|
return sibling
|
|
}
|
|
sibling = sibling.nextElementSibling
|
|
}
|
|
}
|
|
|
|
/* Panel Stuff */
|
|
|
|
// true = open
|
|
let COLLAPSIBLES_INITIALIZED = false
|
|
const COLLAPSIBLES_KEY = "collapsibles"
|
|
const COLLAPSIBLE_PANELS = [] // filled in by createCollapsibles with all the elements matching .collapsible
|
|
|
|
// on-init call this for any panels that are marked open
|
|
function toggleCollapsible(element) {
|
|
const collapsibleHeader = element.querySelector(".collapsible")
|
|
const handle = element.querySelector(".collapsible-handle")
|
|
collapsibleHeader.classList.toggle("active")
|
|
let content = getNextSibling(collapsibleHeader, ".collapsible-content")
|
|
if (!collapsibleHeader.classList.contains("active")) {
|
|
content.style.display = "none"
|
|
if (handle != null) {
|
|
// render results don't have a handle
|
|
handle.innerHTML = "➕" // plus
|
|
}
|
|
} else {
|
|
content.style.display = "block"
|
|
if (handle != null) {
|
|
// render results don't have a handle
|
|
handle.innerHTML = "➖" // minus
|
|
}
|
|
}
|
|
document.dispatchEvent(new CustomEvent("collapsibleClick", { detail: collapsibleHeader }))
|
|
|
|
if (COLLAPSIBLES_INITIALIZED && COLLAPSIBLE_PANELS.includes(element)) {
|
|
saveCollapsibles()
|
|
}
|
|
}
|
|
|
|
function saveCollapsibles() {
|
|
let values = {}
|
|
COLLAPSIBLE_PANELS.forEach((element) => {
|
|
let value = element.querySelector(".collapsible").className.indexOf("active") !== -1
|
|
values[element.id] = value
|
|
})
|
|
localStorage.setItem(COLLAPSIBLES_KEY, JSON.stringify(values))
|
|
}
|
|
|
|
function createCollapsibles(node) {
|
|
let save = false
|
|
if (!node) {
|
|
node = document
|
|
save = true
|
|
}
|
|
let collapsibles = node.querySelectorAll(".collapsible")
|
|
collapsibles.forEach(function(c) {
|
|
if (save && c.parentElement.id) {
|
|
COLLAPSIBLE_PANELS.push(c.parentElement)
|
|
}
|
|
let handle = document.createElement("span")
|
|
handle.className = "collapsible-handle"
|
|
|
|
if (c.classList.contains("active")) {
|
|
handle.innerHTML = "➖" // minus
|
|
} else {
|
|
handle.innerHTML = "➕" // plus
|
|
}
|
|
c.insertBefore(handle, c.firstChild)
|
|
|
|
c.addEventListener("click", function() {
|
|
toggleCollapsible(c.parentElement)
|
|
})
|
|
})
|
|
if (save) {
|
|
let saved = localStorage.getItem(COLLAPSIBLES_KEY)
|
|
if (!saved) {
|
|
saved = tryLoadOldCollapsibles()
|
|
}
|
|
if (!saved) {
|
|
saveCollapsibles()
|
|
saved = localStorage.getItem(COLLAPSIBLES_KEY)
|
|
}
|
|
let values = JSON.parse(saved)
|
|
COLLAPSIBLE_PANELS.forEach((element) => {
|
|
let value = element.querySelector(".collapsible").className.indexOf("active") !== -1
|
|
if (values[element.id] != value) {
|
|
toggleCollapsible(element)
|
|
}
|
|
})
|
|
COLLAPSIBLES_INITIALIZED = true
|
|
}
|
|
}
|
|
|
|
function tryLoadOldCollapsibles() {
|
|
const old_map = {
|
|
advancedPanelOpen: "editor-settings",
|
|
modifiersPanelOpen: "editor-modifiers",
|
|
negativePromptPanelOpen: "editor-inputs-prompt",
|
|
}
|
|
if (localStorage.getItem(Object.keys(old_map)[0])) {
|
|
let result = {}
|
|
Object.keys(old_map).forEach((key) => {
|
|
const value = localStorage.getItem(key)
|
|
if (value !== null) {
|
|
result[old_map[key]] = value == true || value == "true"
|
|
localStorage.removeItem(key)
|
|
}
|
|
})
|
|
result = JSON.stringify(result)
|
|
localStorage.setItem(COLLAPSIBLES_KEY, result)
|
|
return result
|
|
}
|
|
return null
|
|
}
|
|
|
|
function permute(arr) {
|
|
let permutations = []
|
|
let n = arr.length
|
|
let n_permutations = Math.pow(2, n)
|
|
for (let i = 0; i < n_permutations; i++) {
|
|
let perm = []
|
|
let mask = Number(i)
|
|
.toString(2)
|
|
.padStart(n, "0")
|
|
|
|
for (let idx = 0; idx < mask.length; idx++) {
|
|
if (mask[idx] === "1" && arr[idx].trim() !== "") {
|
|
perm.push(arr[idx])
|
|
}
|
|
}
|
|
|
|
if (perm.length > 0) {
|
|
permutations.push(perm)
|
|
}
|
|
}
|
|
|
|
return permutations
|
|
}
|
|
|
|
// https://stackoverflow.com/a/8212878
|
|
function millisecondsToStr(milliseconds) {
|
|
function numberEnding(number) {
|
|
return number > 1 ? "s" : ""
|
|
}
|
|
|
|
let temp = Math.floor(milliseconds / 1000)
|
|
let hours = Math.floor((temp %= 86400) / 3600)
|
|
let s = ""
|
|
if (hours) {
|
|
s += hours + " hour" + numberEnding(hours) + " "
|
|
}
|
|
let minutes = Math.floor((temp %= 3600) / 60)
|
|
if (minutes) {
|
|
s += minutes + " minute" + numberEnding(minutes) + " "
|
|
}
|
|
let seconds = temp % 60
|
|
if (!hours && minutes < 4 && seconds) {
|
|
s += seconds + " second" + numberEnding(seconds)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// https://rosettacode.org/wiki/Brace_expansion#JavaScript
|
|
function BraceExpander() {
|
|
"use strict"
|
|
|
|
// Index of any closing brace matching the opening
|
|
// brace at iPosn,
|
|
// with the indices of any immediately-enclosed commas.
|
|
function bracePair(tkns, iPosn, iNest, lstCommas) {
|
|
if (iPosn >= tkns.length || iPosn < 0) return null
|
|
|
|
let t = tkns[iPosn],
|
|
n = t === "{" ? iNest + 1 : t === "}" ? iNest - 1 : iNest,
|
|
lst = t === "," && iNest === 1 ? lstCommas.concat(iPosn) : lstCommas
|
|
|
|
return n
|
|
? bracePair(tkns, iPosn + 1, n, lst)
|
|
: {
|
|
close: iPosn,
|
|
commas: lst,
|
|
}
|
|
}
|
|
|
|
// Parse of a SYNTAGM subtree
|
|
function andTree(dctSofar, tkns) {
|
|
if (!tkns.length) return [dctSofar, []]
|
|
|
|
let dctParse = dctSofar
|
|
? dctSofar
|
|
: {
|
|
fn: and,
|
|
args: [],
|
|
},
|
|
head = tkns[0],
|
|
tail = head ? tkns.slice(1) : [],
|
|
dctBrace = head === "{" ? bracePair(tkns, 0, 0, []) : null,
|
|
lstOR = dctBrace && dctBrace.close && dctBrace.commas.length ? splitAt(dctBrace.close + 1, tkns) : null
|
|
|
|
return andTree(
|
|
{
|
|
fn: and,
|
|
args: dctParse.args.concat(lstOR ? orTree(dctParse, lstOR[0], dctBrace.commas) : head),
|
|
},
|
|
lstOR ? lstOR[1] : tail
|
|
)
|
|
}
|
|
|
|
// Parse of a PARADIGM subtree
|
|
function orTree(dctSofar, tkns, lstCommas) {
|
|
if (!tkns.length) return [dctSofar, []]
|
|
let iLast = lstCommas.length
|
|
|
|
return {
|
|
fn: or,
|
|
args: splitsAt(lstCommas, tkns)
|
|
.map(function(x, i) {
|
|
let ts = x.slice(1, i === iLast ? -1 : void 0)
|
|
|
|
return ts.length ? ts : [""]
|
|
})
|
|
.map(function(ts) {
|
|
return ts.length > 1 ? andTree(null, ts)[0] : ts[0]
|
|
}),
|
|
}
|
|
}
|
|
|
|
// List of unescaped braces and commas, and remaining strings
|
|
function tokens(str) {
|
|
// Filter function excludes empty splitting artefacts
|
|
let toS = function(x) {
|
|
return x.toString()
|
|
}
|
|
|
|
return str
|
|
.split(/(\\\\)/)
|
|
.filter(toS)
|
|
.reduce(function(a, s) {
|
|
return a.concat(s.charAt(0) === "\\" ? s : s.split(/(\\*[{,}])/).filter(toS))
|
|
}, [])
|
|
}
|
|
|
|
// PARSE TREE OPERATOR (1 of 2)
|
|
// Each possible head * each possible tail
|
|
function and(args) {
|
|
let lng = args.length,
|
|
head = lng ? args[0] : null,
|
|
lstHead = "string" === typeof head ? [head] : head
|
|
|
|
return lng
|
|
? 1 < lng
|
|
? lstHead.reduce(function(a, h) {
|
|
return a.concat(
|
|
and(args.slice(1)).map(function(t) {
|
|
return h + t
|
|
})
|
|
)
|
|
}, [])
|
|
: lstHead
|
|
: []
|
|
}
|
|
|
|
// PARSE TREE OPERATOR (2 of 2)
|
|
// Each option flattened
|
|
function or(args) {
|
|
return args.reduce(function(a, b) {
|
|
return a.concat(b)
|
|
}, [])
|
|
}
|
|
|
|
// One list split into two (first sublist length n)
|
|
function splitAt(n, lst) {
|
|
return n < lst.length + 1 ? [lst.slice(0, n), lst.slice(n)] : [lst, []]
|
|
}
|
|
|
|
// One list split into several (sublist lengths [n])
|
|
function splitsAt(lstN, lst) {
|
|
return lstN.reduceRight(
|
|
function(a, x) {
|
|
return splitAt(x, a[0]).concat(a.slice(1))
|
|
},
|
|
[lst]
|
|
)
|
|
}
|
|
|
|
// Value of the parse tree
|
|
function evaluated(e) {
|
|
return typeof e === "string" ? e : e.fn(e.args.map(evaluated))
|
|
}
|
|
|
|
// JSON prettyprint (for parse tree, token list etc)
|
|
function pp(e) {
|
|
return JSON.stringify(
|
|
e,
|
|
function(k, v) {
|
|
return typeof v === "function" ? "[function " + v.name + "]" : v
|
|
},
|
|
2
|
|
)
|
|
}
|
|
|
|
// ----------------------- MAIN ------------------------
|
|
|
|
// s -> [s]
|
|
this.expand = function(s) {
|
|
// BRACE EXPRESSION PARSED
|
|
let dctParse = andTree(null, tokens(s))[0]
|
|
|
|
// ABSTRACT SYNTAX TREE LOGGED
|
|
// console.log(pp(dctParse));
|
|
|
|
// AST EVALUATED TO LIST OF STRINGS
|
|
return evaluated(dctParse)
|
|
}
|
|
}
|
|
|
|
/** Pause the execution of an async function until timer elapse.
|
|
* @Returns a promise that will resolve after the specified timeout.
|
|
*/
|
|
function asyncDelay(timeout) {
|
|
return new Promise(function(resolve, reject) {
|
|
setTimeout(resolve, timeout, true)
|
|
})
|
|
}
|
|
|
|
function PromiseSource() {
|
|
const srcPromise = new Promise((resolve, reject) => {
|
|
Object.defineProperties(this, {
|
|
resolve: { value: resolve, writable: false },
|
|
reject: { value: reject, writable: false },
|
|
})
|
|
})
|
|
Object.defineProperties(this, {
|
|
promise: { value: makeQuerablePromise(srcPromise), writable: false },
|
|
})
|
|
}
|
|
|
|
/** A debounce is a higher-order function, which is a function that returns another function
|
|
* that, as long as it continues to be invoked, will not be triggered.
|
|
* The function will be called after it stops being called for N milliseconds.
|
|
* If `immediate` is passed, trigger the function on the leading edge, instead of the trailing.
|
|
* @Returns a promise that will resolve to func return value.
|
|
*/
|
|
function debounce(func, wait, immediate) {
|
|
if (typeof wait === "undefined") {
|
|
wait = 40
|
|
}
|
|
if (typeof wait !== "number") {
|
|
throw new Error("wait is not an number.")
|
|
}
|
|
let timeout = null
|
|
let lastPromiseSrc = new PromiseSource()
|
|
const applyFn = function(context, args) {
|
|
let result = undefined
|
|
try {
|
|
result = func.apply(context, args)
|
|
} catch (err) {
|
|
lastPromiseSrc.reject(err)
|
|
}
|
|
if (result instanceof Promise) {
|
|
result.then(lastPromiseSrc.resolve, lastPromiseSrc.reject)
|
|
} else {
|
|
lastPromiseSrc.resolve(result)
|
|
}
|
|
}
|
|
return function(...args) {
|
|
const callNow = Boolean(immediate && !timeout)
|
|
const context = this
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
}
|
|
timeout = setTimeout(function() {
|
|
if (!immediate) {
|
|
applyFn(context, args)
|
|
}
|
|
lastPromiseSrc = new PromiseSource()
|
|
timeout = null
|
|
}, wait)
|
|
if (callNow) {
|
|
applyFn(context, args)
|
|
}
|
|
return lastPromiseSrc.promise
|
|
}
|
|
}
|
|
|
|
function preventNonNumericalInput(e) {
|
|
e = e || window.event
|
|
let charCode = typeof e.which == "undefined" ? e.keyCode : e.which
|
|
let charStr = String.fromCharCode(charCode)
|
|
let re = e.target.getAttribute("pattern") || "^[0-9]+$"
|
|
re = new RegExp(re)
|
|
|
|
if (!charStr.match(re)) {
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
|
|
/** Returns the global object for the current execution environement.
|
|
* @Returns window in a browser, global in node and self in a ServiceWorker.
|
|
* @Notes Allows unit testing and use of the engine outside of a browser.
|
|
*/
|
|
function getGlobal() {
|
|
if (typeof globalThis === "object") {
|
|
return globalThis
|
|
} else if (typeof global === "object") {
|
|
return global
|
|
} else if (typeof self === "object") {
|
|
return self
|
|
}
|
|
try {
|
|
return Function("return this")()
|
|
} catch {
|
|
// If the Function constructor fails, we're in a browser with eval disabled by CSP headers.
|
|
return window
|
|
} // Returns undefined if global can't be found.
|
|
}
|
|
|
|
/** Check if x is an Array or a TypedArray.
|
|
* @Returns true if x is an Array or a TypedArray, false otherwise.
|
|
*/
|
|
function isArrayOrTypedArray(x) {
|
|
return Boolean(typeof x === "object" && (Array.isArray(x) || (ArrayBuffer.isView(x) && !(x instanceof DataView))))
|
|
}
|
|
|
|
function makeQuerablePromise(promise) {
|
|
if (typeof promise !== "object") {
|
|
throw new Error("promise is not an object.")
|
|
}
|
|
if (!(promise instanceof Promise)) {
|
|
throw new Error("Argument is not a promise.")
|
|
}
|
|
// Don't modify a promise that's been already modified.
|
|
if ("isResolved" in promise || "isRejected" in promise || "isPending" in promise) {
|
|
return promise
|
|
}
|
|
let isPending = true
|
|
let isRejected = false
|
|
let rejectReason = undefined
|
|
let isResolved = false
|
|
let resolvedValue = undefined
|
|
const qurPro = promise.then(
|
|
function(val) {
|
|
isResolved = true
|
|
isPending = false
|
|
resolvedValue = val
|
|
return val
|
|
},
|
|
function(reason) {
|
|
rejectReason = reason
|
|
isRejected = true
|
|
isPending = false
|
|
throw reason
|
|
}
|
|
)
|
|
Object.defineProperties(qurPro, {
|
|
isResolved: {
|
|
get: () => isResolved,
|
|
},
|
|
resolvedValue: {
|
|
get: () => resolvedValue,
|
|
},
|
|
isPending: {
|
|
get: () => isPending,
|
|
},
|
|
isRejected: {
|
|
get: () => isRejected,
|
|
},
|
|
rejectReason: {
|
|
get: () => rejectReason,
|
|
},
|
|
})
|
|
return qurPro
|
|
}
|
|
|
|
/* inserts custom html to allow prettifying of inputs */
|
|
function prettifyInputs(root_element) {
|
|
root_element.querySelectorAll(`input[type="checkbox"]`).forEach((element) => {
|
|
if (element.style.display === "none") {
|
|
return
|
|
}
|
|
var parent = element.parentNode
|
|
if (!parent.classList.contains("input-toggle")) {
|
|
var wrapper = document.createElement("div")
|
|
wrapper.classList.add("input-toggle")
|
|
parent.replaceChild(wrapper, element)
|
|
wrapper.appendChild(element)
|
|
var label = document.createElement("label")
|
|
label.htmlFor = element.id
|
|
wrapper.appendChild(label)
|
|
}
|
|
})
|
|
}
|
|
|
|
class GenericEventSource {
|
|
#events = {}
|
|
#types = []
|
|
constructor(...eventsTypes) {
|
|
if (Array.isArray(eventsTypes) && eventsTypes.length === 1 && Array.isArray(eventsTypes[0])) {
|
|
eventsTypes = eventsTypes[0]
|
|
}
|
|
this.#types.push(...eventsTypes)
|
|
}
|
|
get eventTypes() {
|
|
return this.#types
|
|
}
|
|
/** Add a new event listener
|
|
*/
|
|
addEventListener(name, handler) {
|
|
if (!this.#types.includes(name)) {
|
|
throw new Error("Invalid event name.")
|
|
}
|
|
if (this.#events.hasOwnProperty(name)) {
|
|
this.#events[name].push(handler)
|
|
} else {
|
|
this.#events[name] = [handler]
|
|
}
|
|
}
|
|
/** Remove the event listener
|
|
*/
|
|
removeEventListener(name, handler) {
|
|
if (!this.#events.hasOwnProperty(name)) {
|
|
return
|
|
}
|
|
const index = this.#events[name].indexOf(handler)
|
|
if (index != -1) {
|
|
this.#events[name].splice(index, 1)
|
|
}
|
|
}
|
|
fireEvent(name, ...args) {
|
|
if (!this.#types.includes(name)) {
|
|
throw new Error(`Event ${String(name)} missing from Events.types`)
|
|
}
|
|
if (!this.#events.hasOwnProperty(name)) {
|
|
return Promise.resolve()
|
|
}
|
|
if (!args || !args.length) {
|
|
args = []
|
|
}
|
|
const evs = this.#events[name]
|
|
if (evs.length <= 0) {
|
|
return Promise.resolve()
|
|
}
|
|
return Promise.allSettled(
|
|
evs.map((callback) => {
|
|
try {
|
|
return Promise.resolve(callback.apply(SD, args))
|
|
} catch (ex) {
|
|
return Promise.reject(ex)
|
|
}
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
class ServiceContainer {
|
|
#services = new Map()
|
|
#singletons = new Map()
|
|
constructor(...servicesParams) {
|
|
servicesParams.forEach(this.register.bind(this))
|
|
}
|
|
get services() {
|
|
return this.#services
|
|
}
|
|
get singletons() {
|
|
return this.#singletons
|
|
}
|
|
register(params) {
|
|
if (ServiceContainer.isConstructor(params)) {
|
|
if (typeof params.name !== "string") {
|
|
throw new Error("params.name is not a string.")
|
|
}
|
|
params = { name: params.name, definition: params }
|
|
}
|
|
if (typeof params !== "object") {
|
|
throw new Error("params is not an object.")
|
|
}
|
|
;["name", "definition"].forEach((key) => {
|
|
if (!(key in params)) {
|
|
console.error("Invalid service %o registration.", params)
|
|
throw new Error(`params.${key} is not defined.`)
|
|
}
|
|
})
|
|
const opts = { definition: params.definition }
|
|
if ("dependencies" in params) {
|
|
if (Array.isArray(params.dependencies)) {
|
|
params.dependencies.forEach((dep) => {
|
|
if (typeof dep !== "string") {
|
|
throw new Error("dependency name is not a string.")
|
|
}
|
|
})
|
|
opts.dependencies = params.dependencies
|
|
} else {
|
|
throw new Error("params.dependencies is not an array.")
|
|
}
|
|
}
|
|
if (params.singleton) {
|
|
opts.singleton = true
|
|
}
|
|
this.#services.set(params.name, opts)
|
|
return Object.assign({ name: params.name }, opts)
|
|
}
|
|
get(name) {
|
|
const ctorInfos = this.#services.get(name)
|
|
if (!ctorInfos) {
|
|
return
|
|
}
|
|
if (!ServiceContainer.isConstructor(ctorInfos.definition)) {
|
|
return ctorInfos.definition
|
|
}
|
|
if (!ctorInfos.singleton) {
|
|
return this._createInstance(ctorInfos)
|
|
}
|
|
const singletonInstance = this.#singletons.get(name)
|
|
if (singletonInstance) {
|
|
return singletonInstance
|
|
}
|
|
const newSingletonInstance = this._createInstance(ctorInfos)
|
|
this.#singletons.set(name, newSingletonInstance)
|
|
return newSingletonInstance
|
|
}
|
|
|
|
_getResolvedDependencies(service) {
|
|
let classDependencies = []
|
|
if (service.dependencies) {
|
|
classDependencies = service.dependencies.map(this.get.bind(this))
|
|
}
|
|
return classDependencies
|
|
}
|
|
|
|
_createInstance(service) {
|
|
if (!ServiceContainer.isClass(service.definition)) {
|
|
// Call as normal function.
|
|
return service.definition(...this._getResolvedDependencies(service))
|
|
}
|
|
// Use new
|
|
return new service.definition(...this._getResolvedDependencies(service))
|
|
}
|
|
|
|
static isClass(definition) {
|
|
return (
|
|
typeof definition === "function" &&
|
|
Boolean(definition.prototype) &&
|
|
definition.prototype.constructor === definition
|
|
)
|
|
}
|
|
static isConstructor(definition) {
|
|
return typeof definition === "function"
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} tag
|
|
* @param {object} attributes
|
|
* @param {string | Array<string>} classes
|
|
* @param {string | Node | Array<string | Node>}
|
|
* @returns {HTMLElement}
|
|
*/
|
|
function createElement(tagName, attributes, classes, textOrElements) {
|
|
const element = document.createElement(tagName)
|
|
if (attributes) {
|
|
Object.entries(attributes).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
element.setAttribute(key, value)
|
|
}
|
|
})
|
|
}
|
|
if (classes) {
|
|
;(Array.isArray(classes) ? classes : [classes]).forEach((className) => element.classList.add(className))
|
|
}
|
|
if (textOrElements) {
|
|
const children = Array.isArray(textOrElements) ? textOrElements : [textOrElements]
|
|
children.forEach((textOrElem) => {
|
|
if (textOrElem instanceof Node) {
|
|
element.appendChild(textOrElem)
|
|
} else {
|
|
element.appendChild(document.createTextNode(textOrElem))
|
|
}
|
|
})
|
|
}
|
|
return element
|
|
}
|
|
|
|
/**
|
|
* Add a listener for arrays
|
|
* @param {keyof Array} method
|
|
* @param {(args) => {}} callback
|
|
*/
|
|
Array.prototype.addEventListener = function(method, callback) {
|
|
const originalFunction = this[method]
|
|
if (originalFunction) {
|
|
this[method] = function() {
|
|
originalFunction.apply(this, arguments)
|
|
callback.apply(this, arguments)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} TabOpenDetails
|
|
* @property {HTMLElement} contentElement
|
|
* @property {HTMLElement} labelElement
|
|
* @property {number} timesOpened
|
|
* @property {boolean} firstOpen
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} CreateTabRequest
|
|
* @property {string} id
|
|
* @property {string | Node | (() => (string | Node))} label
|
|
* Label text or an HTML element
|
|
* @property {string} icon
|
|
* @property {string | Node | Promise<string | Node> | (() => (string | Node | Promise<string | Node>)) | undefined} content
|
|
* HTML string or HTML element
|
|
* @property {((TabOpenDetails, Event) => (undefined | string | Node | Promise<string | Node>)) | undefined} onOpen
|
|
* If an HTML string or HTML element is returned, then that will replace the tab content
|
|
* @property {string | undefined} css
|
|
*/
|
|
|
|
/**
|
|
* @param {CreateTabRequest} request
|
|
*/
|
|
function createTab(request) {
|
|
if (!request?.id) {
|
|
console.error("createTab() error - id is required", Error().stack)
|
|
return
|
|
}
|
|
|
|
if (!request.label) {
|
|
console.error("createTab() error - label is required", Error().stack)
|
|
return
|
|
}
|
|
|
|
if (!request.icon) {
|
|
console.error("createTab() error - icon is required", Error().stack)
|
|
return
|
|
}
|
|
|
|
if (!request.content && !request.onOpen) {
|
|
console.error("createTab() error - content or onOpen required", Error().stack)
|
|
return
|
|
}
|
|
|
|
const tabsContainer = document.querySelector(".tab-container")
|
|
if (!tabsContainer) {
|
|
return
|
|
}
|
|
|
|
const tabsContentWrapper = document.querySelector("#tab-content-wrapper")
|
|
if (!tabsContentWrapper) {
|
|
return
|
|
}
|
|
|
|
// console.debug('creating tab: ', request)
|
|
|
|
if (request.css) {
|
|
document
|
|
.querySelector("body")
|
|
.insertAdjacentElement(
|
|
"beforeend",
|
|
createElement("style", { id: `tab-${request.id}-css` }, undefined, request.css)
|
|
)
|
|
}
|
|
|
|
const label = typeof request.label === "function" ? request.label() : request.label
|
|
const labelElement = label instanceof Node ? label : createElement("span", undefined, undefined, label)
|
|
|
|
const tab = createElement(
|
|
"span",
|
|
{ id: `tab-${request.id}`, "data-times-opened": 0 },
|
|
["tab"],
|
|
createElement("span", undefined, undefined, [
|
|
createElement("i", { style: "margin-right: 0.25em" }, [
|
|
"fa-solid",
|
|
`${request.icon.startsWith("fa-") ? "" : "fa-"}${request.icon}`,
|
|
"icon",
|
|
]),
|
|
labelElement,
|
|
])
|
|
)
|
|
|
|
tabsContainer.insertAdjacentElement("beforeend", tab)
|
|
|
|
const wrapper = createElement("div", { id: request.id }, ["tab-content-inner"], "Loading..")
|
|
|
|
const tabContent = createElement("div", { id: `tab-content-${request.id}` }, ["tab-content"], wrapper)
|
|
tabsContentWrapper.insertAdjacentElement("beforeend", tabContent)
|
|
|
|
linkTabContents(tab)
|
|
|
|
function replaceContent(resultFactory) {
|
|
if (resultFactory === undefined || resultFactory === null) {
|
|
return
|
|
}
|
|
const result = typeof resultFactory === "function" ? resultFactory() : resultFactory
|
|
if (result instanceof Promise) {
|
|
result.then(replaceContent)
|
|
} else if (result instanceof Node) {
|
|
wrapper.replaceChildren(result)
|
|
} else {
|
|
wrapper.innerHTML = result
|
|
}
|
|
}
|
|
|
|
replaceContent(request.content)
|
|
|
|
tab.addEventListener("click", (e) => {
|
|
const timesOpened = +(tab.dataset.timesOpened || 0) + 1
|
|
tab.dataset.timesOpened = timesOpened
|
|
|
|
if (request.onOpen) {
|
|
const result = request.onOpen(
|
|
{
|
|
contentElement: wrapper,
|
|
labelElement,
|
|
timesOpened,
|
|
firstOpen: timesOpened === 1,
|
|
},
|
|
e
|
|
)
|
|
|
|
replaceContent(result)
|
|
}
|
|
})
|
|
}
|
|
|
|
/* TOAST NOTIFICATIONS */
|
|
function showToast(message, duration = 5000, error = false) {
|
|
const toast = document.createElement("div")
|
|
toast.classList.add("toast-notification")
|
|
if (error === true) {
|
|
toast.classList.add("toast-notification-error")
|
|
}
|
|
toast.innerHTML = message
|
|
document.body.appendChild(toast)
|
|
|
|
// Set the position of the toast on the screen
|
|
const toastCount = document.querySelectorAll(".toast-notification").length
|
|
const toastHeight = toast.offsetHeight
|
|
const previousToastsHeight = Array.from(document.querySelectorAll(".toast-notification"))
|
|
.slice(0, -1) // exclude current toast
|
|
.reduce((totalHeight, toast) => totalHeight + toast.offsetHeight + 10, 0) // add 10 pixels for spacing
|
|
toast.style.bottom = `${10 + previousToastsHeight}px`
|
|
toast.style.right = "10px"
|
|
|
|
// Delay the removal of the toast until animation has completed
|
|
const removeToast = () => {
|
|
toast.classList.add("hide")
|
|
const removeTimeoutId = setTimeout(() => {
|
|
toast.remove()
|
|
// Adjust the position of remaining toasts
|
|
const remainingToasts = document.querySelectorAll(".toast-notification")
|
|
const removedToastBottom = toast.getBoundingClientRect().bottom
|
|
|
|
remainingToasts.forEach((toast) => {
|
|
if (toast.getBoundingClientRect().bottom < removedToastBottom) {
|
|
toast.classList.add("slide-down")
|
|
}
|
|
})
|
|
|
|
// Wait for the slide-down animation to complete
|
|
setTimeout(() => {
|
|
// Remove the slide-down class after the animation has completed
|
|
const slidingToasts = document.querySelectorAll(".slide-down")
|
|
slidingToasts.forEach((toast) => {
|
|
toast.classList.remove("slide-down")
|
|
})
|
|
|
|
// Adjust the position of remaining toasts again, in case there are multiple toasts being removed at once
|
|
const remainingToastsDown = document.querySelectorAll(".toast-notification")
|
|
let heightSoFar = 0
|
|
remainingToastsDown.forEach((toast) => {
|
|
toast.style.bottom = `${10 + heightSoFar}px`
|
|
heightSoFar += toast.offsetHeight + 10 // add 10 pixels for spacing
|
|
})
|
|
}, 0) // The duration of the slide-down animation (in milliseconds)
|
|
}, 500)
|
|
}
|
|
|
|
// Remove the toast after specified duration
|
|
setTimeout(removeToast, duration)
|
|
}
|
|
|
|
function alert(msg, title) {
|
|
title = title || ""
|
|
$.alert({
|
|
theme: "modern",
|
|
title: title,
|
|
useBootstrap: false,
|
|
animateFromElement: false,
|
|
content: msg,
|
|
})
|
|
}
|
|
|
|
function confirm(msg, title, fn) {
|
|
title = title || ""
|
|
$.confirm({
|
|
theme: "modern",
|
|
title: title,
|
|
useBootstrap: false,
|
|
animateFromElement: false,
|
|
content: msg,
|
|
buttons: {
|
|
yes: fn,
|
|
cancel: () => {},
|
|
},
|
|
})
|
|
}
|