bea3c0fbe4
refresh button on right-hand side of model selector is definitely better GUARANTEE css is distressingly hacky
426 lines
11 KiB
JavaScript
426 lines
11 KiB
JavaScript
/**
|
|
* This is a function that makes an HTMLElement draggable.
|
|
*
|
|
* The element must contain at least one child element with the class
|
|
* 'draggable', which will make it the handle for dragging the element
|
|
*
|
|
* @param {HTMLElement} element Element to make Draggable
|
|
*/
|
|
function makeDraggable(element) {
|
|
let dragging = false;
|
|
let offset = {x: 0, y: 0};
|
|
|
|
const margin = 10;
|
|
|
|
// Keeps the draggable element inside the window
|
|
const fixPos = () => {
|
|
const dbb = element.getBoundingClientRect();
|
|
if (dbb.left < margin) element.style.left = margin + "px";
|
|
else if (dbb.right > window.innerWidth - margin)
|
|
element.style.left =
|
|
dbb.left + (window.innerWidth - margin - dbb.right) + "px";
|
|
|
|
if (dbb.top < margin) element.style.top = margin + "px";
|
|
else if (dbb.bottom > window.innerHeight - margin)
|
|
element.style.top =
|
|
dbb.top + (window.innerHeight - margin - dbb.bottom) + "px";
|
|
};
|
|
|
|
// Detects the start of the mouse dragging event
|
|
mouse.listen.window.btn.left.onpaintstart.on((evn) => {
|
|
if (
|
|
element.contains(evn.target) &&
|
|
evn.target.classList.contains("draggable")
|
|
) {
|
|
const bb = element.getBoundingClientRect();
|
|
offset.x = evn.x - bb.x;
|
|
offset.y = evn.y - bb.y;
|
|
dragging = true;
|
|
}
|
|
});
|
|
|
|
// Runs when mouse moves
|
|
mouse.listen.window.btn.left.onpaint.on((evn) => {
|
|
if (dragging) {
|
|
element.style.right = null;
|
|
element.style.bottom = null;
|
|
element.style.top = evn.y - offset.y + "px";
|
|
element.style.left = evn.x - offset.x + "px";
|
|
|
|
fixPos();
|
|
}
|
|
});
|
|
|
|
// Stops dragging the element
|
|
mouse.listen.window.btn.left.onpaintend.on((evn) => {
|
|
dragging = false;
|
|
});
|
|
|
|
// Redraw after window resize
|
|
window.addEventListener("resize", () => {
|
|
fixPos();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates a custom slider element from a given div element
|
|
*
|
|
* @param {string} name The display name of the sliders
|
|
* @param {HTMLElement} wrapper The element to transform into a slider
|
|
* @param {object} options Extra options
|
|
* @param {number} options.min The minimum value of the slider
|
|
* @param {number} options.max The maximum value of the slider
|
|
* @param {number} options.step The step size for the slider
|
|
* @param {number} option.defaultValue The default value of the slider
|
|
* @param {number} [options.textStep=step] The step size for the slider text and setvalue \
|
|
* (usually finer, and an integer divisor of step size)
|
|
* @returns {{value: number, onchange: Observer<{value: number}>}} A reference to the value of the slider
|
|
*/
|
|
function createSlider(name, wrapper, options = {}) {
|
|
defaultOpt(options, {
|
|
valuecb: null,
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.1,
|
|
defaultValue: 0.7,
|
|
textStep: null,
|
|
});
|
|
|
|
let value = options.defaultValue;
|
|
|
|
// Use phantom range element for rounding
|
|
const phantomRange = document.createElement("input");
|
|
phantomRange.type = "range";
|
|
phantomRange.min = options.min;
|
|
phantomRange.max = options.max;
|
|
phantomRange.step = options.step;
|
|
|
|
let phantomTextRange = phantomRange;
|
|
if (options.textStep) {
|
|
phantomTextRange = document.createElement("input");
|
|
phantomTextRange.type = "range";
|
|
phantomTextRange.min = options.min;
|
|
phantomTextRange.max = options.max;
|
|
phantomTextRange.step = options.textStep;
|
|
}
|
|
|
|
// Build slider element
|
|
const underEl = document.createElement("div");
|
|
underEl.classList.add("under");
|
|
const textEl = document.createElement("input");
|
|
textEl.type = "text";
|
|
textEl.classList.add("text");
|
|
|
|
const overEl = document.createElement("div");
|
|
overEl.classList.add("over");
|
|
|
|
wrapper.classList.add("slider-wrapper");
|
|
wrapper.appendChild(underEl);
|
|
wrapper.appendChild(textEl);
|
|
wrapper.appendChild(overEl);
|
|
|
|
const bar = document.createElement("div");
|
|
bar.classList.add("slider-bar");
|
|
underEl.appendChild(bar);
|
|
underEl.appendChild(document.createElement("div"));
|
|
|
|
// Change observer
|
|
/** @type {Observer<{value: number}>} */
|
|
const onchange = new Observer();
|
|
|
|
// Set value
|
|
const setValue = (val) => {
|
|
phantomTextRange.value = val;
|
|
value = parseFloat(phantomTextRange.value);
|
|
bar.style.width = `${
|
|
100 * ((value - options.min) / (options.max - options.min))
|
|
}%`;
|
|
textEl.value = `${name}: ${value}`;
|
|
options.valuecb && options.valuecb(value);
|
|
onchange.emit({value: val});
|
|
};
|
|
|
|
setValue(options.defaultValue);
|
|
|
|
// Events
|
|
textEl.addEventListener("blur", () => {
|
|
overEl.style.pointerEvents = "auto";
|
|
textEl.value = `${name}: ${value}`;
|
|
});
|
|
textEl.addEventListener("focus", () => {
|
|
overEl.style.pointerEvents = "none";
|
|
textEl.value = value;
|
|
});
|
|
|
|
textEl.addEventListener("change", () => {
|
|
try {
|
|
if (Number.isNaN(parseFloat(textEl.value))) setValue(value);
|
|
else setValue(parseFloat(textEl.value));
|
|
} catch (e) {}
|
|
});
|
|
|
|
keyboard.listen.onkeyclick.on((evn) => {
|
|
if (evn.target === textEl && evn.code === "Enter") {
|
|
textEl.blur();
|
|
}
|
|
});
|
|
|
|
mouse.listen.window.btn.left.onclick.on((evn) => {
|
|
if (evn.target === overEl) {
|
|
textEl.select();
|
|
}
|
|
});
|
|
|
|
mouse.listen.window.btn.left.ondrag.on((evn) => {
|
|
if (evn.initialTarget === overEl) {
|
|
const newv = Math.max(
|
|
options.min,
|
|
Math.min(
|
|
options.max,
|
|
((evn.evn.clientX - evn.initialTarget.getBoundingClientRect().left) /
|
|
wrapper.offsetWidth) *
|
|
(options.max - options.min) +
|
|
options.min
|
|
)
|
|
);
|
|
phantomRange.value = newv;
|
|
setValue(parseFloat(phantomRange.value));
|
|
}
|
|
});
|
|
|
|
return {
|
|
onchange,
|
|
set value(val) {
|
|
setValue(val);
|
|
},
|
|
get value() {
|
|
return value;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A function to transform a div into a autocompletable select element
|
|
*
|
|
* @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 {boolean} options.multiple Whether multiple options can be selected
|
|
* @param {{name: string, value: string, optionelcb: (el: HTMLOptionElement) => void}[]} options.options Options to add to the selector
|
|
* @param {object} extraEl Additional element to include in wrapper div (e.g. model refresh button)
|
|
* @param {string} extraClass Additional class to attach to the autocomplete input element
|
|
* @returns {AutoCompleteElement}
|
|
*/
|
|
function createAutoComplete(
|
|
name,
|
|
wrapper,
|
|
options = {},
|
|
extraEl = null,
|
|
extraClass = null
|
|
) {
|
|
defaultOpt(options, {
|
|
multiple: false,
|
|
options: [],
|
|
});
|
|
|
|
wrapper.classList.add("autocomplete");
|
|
|
|
const inputEl = document.createElement("input");
|
|
inputEl.type = "text";
|
|
inputEl.classList.add("autocomplete-text");
|
|
if (extraClass != null) {
|
|
inputEl.classList.add(extraClass);
|
|
}
|
|
|
|
const autocompleteEl = document.createElement("div");
|
|
autocompleteEl.classList.add("autocomplete-list", "display-none");
|
|
|
|
let timeout = null;
|
|
let ontext = false;
|
|
let onlist = false;
|
|
|
|
wrapper.appendChild(inputEl);
|
|
wrapper.appendChild(autocompleteEl);
|
|
if (extraEl != null) {
|
|
wrapper.appendChild(extraEl);
|
|
}
|
|
|
|
const acobj = {
|
|
name,
|
|
wrapper,
|
|
_selectedOptions: new Set(),
|
|
_options: [],
|
|
|
|
/** @type {Observer<{name:string, value: string}>} */
|
|
onchange: new Observer(),
|
|
|
|
get value() {
|
|
const v = Array.from(this._selectedOptions).map((opt) => opt.value);
|
|
return options.multiple ? v : v[0];
|
|
},
|
|
set value(values) {
|
|
this._selectedOptions.clear();
|
|
|
|
for (const val of options.multiple ? values : [values]) {
|
|
const opt = this.options.find((option) => option.value === val);
|
|
|
|
if (!opt) continue; // Ignore invalid options
|
|
|
|
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() {
|
|
return this._options;
|
|
},
|
|
set options(val) {
|
|
this._options = [];
|
|
|
|
while (autocompleteEl.lastChild) {
|
|
autocompleteEl.removeChild(autocompleteEl.lastChild);
|
|
}
|
|
|
|
// Add options
|
|
val.forEach((opt) => {
|
|
const {name, value, title} = opt;
|
|
|
|
const optionEl = document.createElement("option");
|
|
optionEl.classList.add("autocomplete-option");
|
|
optionEl.title = title || name;
|
|
if (opt.optionelcb) opt.optionelcb(optionEl);
|
|
|
|
const option = {name, value, optionElement: optionEl};
|
|
|
|
this._options.push(option);
|
|
|
|
optionEl.addEventListener("click", () => select(option));
|
|
|
|
autocompleteEl.appendChild(optionEl);
|
|
});
|
|
|
|
updateOptions("");
|
|
},
|
|
};
|
|
|
|
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(value = null) {
|
|
const text = value ?? inputEl.value.toLowerCase().trim();
|
|
|
|
acobj._options.forEach((opt) => {
|
|
const textLocation = opt.name.toLowerCase().indexOf(text);
|
|
|
|
while (opt.optionElement.lastChild) {
|
|
opt.optionElement.removeChild(opt.optionElement.lastChild);
|
|
}
|
|
|
|
opt.optionElement.append(
|
|
document.createTextNode(opt.name.substring(0, textLocation))
|
|
);
|
|
const span = document.createElement("span");
|
|
span.style.fontWeight = "bold";
|
|
span.textContent = opt.name.substring(
|
|
textLocation,
|
|
textLocation + text.length
|
|
);
|
|
opt.optionElement.appendChild(span);
|
|
opt.optionElement.appendChild(
|
|
document.createTextNode(
|
|
opt.name.substring(textLocation + text.length, opt.name.length)
|
|
)
|
|
);
|
|
|
|
if (textLocation !== -1) {
|
|
opt.optionElement.classList.remove("display-none");
|
|
} else opt.optionElement.classList.add("display-none");
|
|
});
|
|
}
|
|
|
|
function select(opt) {
|
|
ontext = false;
|
|
if (!options.multiple) {
|
|
onlist = false;
|
|
acobj._selectedOptions.clear();
|
|
autocompleteEl.classList.add("display-none");
|
|
for (const child of autocompleteEl.children) {
|
|
child.classList.remove("selected");
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
acobj._sync();
|
|
}
|
|
|
|
inputEl.addEventListener("focus", () => {
|
|
ontext = true;
|
|
|
|
autocompleteEl.classList.remove("display-none");
|
|
inputEl.select();
|
|
});
|
|
inputEl.addEventListener("blur", () => {
|
|
ontext = false;
|
|
|
|
if (!onlist && !ontext) {
|
|
updateInputField();
|
|
|
|
autocompleteEl.classList.add("display-none");
|
|
}
|
|
});
|
|
|
|
autocompleteEl.addEventListener("mouseenter", () => {
|
|
onlist = true;
|
|
});
|
|
|
|
autocompleteEl.addEventListener("mouseleave", () => {
|
|
onlist = false;
|
|
|
|
if (!onlist && !ontext) {
|
|
updateInputField();
|
|
|
|
autocompleteEl.classList.add("display-none");
|
|
}
|
|
});
|
|
|
|
// Filter
|
|
inputEl.addEventListener("input", () => {
|
|
updateOptions();
|
|
});
|
|
|
|
acobj.options = options.options;
|
|
|
|
return acobj;
|
|
}
|