665 lines
17 KiB
JavaScript
665 lines
17 KiB
JavaScript
/*
|
|
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
|
|
|
See the /faq/tos page for details.
|
|
|
|
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
|
*/
|
|
|
|
function GetViewableWidthAccountingForScrollbar()
|
|
{
|
|
// Check if a vertical scrollbar is present
|
|
const isVerticalScrollbarPresent = document.documentElement.scrollHeight > window.innerHeight;
|
|
|
|
// If no vertical scrollbar, return the innerWidth as is
|
|
if (!isVerticalScrollbarPresent) {
|
|
return window.innerWidth;
|
|
}
|
|
|
|
// Create a temporary element to measure the scrollbar width
|
|
const div = document.createElement('div');
|
|
div.style.visibility = 'hidden'; // Make sure it's not visible
|
|
div.style.position = 'absolute';
|
|
div.style.width = '100px'; // Set a fixed width for the element
|
|
div.style.overflow = 'scroll'; // Enable scroll to ensure a scrollbar appears
|
|
document.body.appendChild(div);
|
|
|
|
// Calculate the scrollbar width
|
|
const scrollbarWidth = div.offsetWidth - div.clientWidth;
|
|
|
|
// Clean up the temporary div
|
|
document.body.removeChild(div);
|
|
|
|
// Return the viewport width excluding the scrollbar
|
|
return window.innerWidth - scrollbarWidth;
|
|
}
|
|
|
|
function GetViewableHeightAccountingForScrollbar()
|
|
{
|
|
// Check if a horizontal scrollbar is present
|
|
const isHorizontalScrollbarPresent = document.documentElement.scrollWidth > window.innerWidth;
|
|
|
|
// If no horizontal scrollbar, return the innerHeight as is
|
|
if (!isHorizontalScrollbarPresent) {
|
|
return window.innerHeight;
|
|
}
|
|
|
|
// Create a temporary element to measure the scrollbar height
|
|
const div = document.createElement('div');
|
|
div.style.visibility = 'hidden'; // Make sure it's not visible
|
|
div.style.position = 'absolute';
|
|
div.style.height = '100px'; // Set a fixed height for the element
|
|
div.style.overflow = 'scroll'; // Enable scroll to ensure a scrollbar appears
|
|
document.body.appendChild(div);
|
|
|
|
// Calculate the scrollbar height
|
|
const scrollbarHeight = div.offsetHeight - div.clientHeight;
|
|
|
|
// Clean up the temporary div
|
|
document.body.removeChild(div);
|
|
|
|
// Return the viewport height excluding the scrollbar
|
|
return window.innerHeight - scrollbarHeight;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ZIndexHelper
|
|
{
|
|
static BASE_Z_INDEX = 1000;
|
|
|
|
constructor()
|
|
{
|
|
this.objDataList = [];
|
|
}
|
|
|
|
// objects register to have a given property set to the zIndex to make them
|
|
// the top-most at this time, and later in the future
|
|
RegisterForTop(obj, prop)
|
|
{
|
|
this.objDataList.push({
|
|
obj,
|
|
prop,
|
|
});
|
|
|
|
this.#AnnounceAll();
|
|
|
|
return this.objDataList.length;
|
|
}
|
|
|
|
// request immediate top level
|
|
RequestTop(obj)
|
|
{
|
|
// find its current location
|
|
let idxFound = -1;
|
|
for (let zIndex = 0; zIndex < this.objDataList.length; ++zIndex)
|
|
{
|
|
let objData = this.objDataList[zIndex];
|
|
|
|
if (objData.obj == obj)
|
|
{
|
|
idxFound = zIndex;
|
|
}
|
|
}
|
|
|
|
if (idxFound != -1)
|
|
{
|
|
// hold temporarily
|
|
let objData = this.objDataList[idxFound];
|
|
|
|
// delete its location, effectively compacting list
|
|
this.objDataList.splice(idxFound, 1);
|
|
|
|
// re-insert
|
|
this.objDataList.push(objData);
|
|
|
|
// announce re-index
|
|
this.#AnnounceAll();
|
|
}
|
|
}
|
|
|
|
#AnnounceAll()
|
|
{
|
|
for (let zIndex = 0; zIndex < this.objDataList.length; ++zIndex)
|
|
{
|
|
let objData = this.objDataList[zIndex];
|
|
|
|
objData.obj[objData.prop] = ZIndexHelper.BASE_Z_INDEX + zIndex;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export class DialogBox
|
|
{
|
|
static #zIndexHelper = new ZIndexHelper();
|
|
static #instanceList = [];
|
|
static #escapeHandlerSet = false;
|
|
|
|
constructor()
|
|
{
|
|
this.isDragging = false;
|
|
this.offsetX = 0;
|
|
this.offsetY = 0;
|
|
|
|
this.ui = this.#MakeUI();
|
|
|
|
DialogBox.#instanceList.push(this);
|
|
DialogBox.#EnsureEscapeHandler();
|
|
}
|
|
|
|
GetUI()
|
|
{
|
|
return this.ui;
|
|
}
|
|
|
|
SetTitleBar(title)
|
|
{
|
|
this.titleBar.innerHTML = title;
|
|
}
|
|
|
|
GetContentContainer()
|
|
{
|
|
return this.frameBody;
|
|
}
|
|
|
|
ToggleShowHide()
|
|
{
|
|
if (this.floatingWindow.style.display === 'none')
|
|
{
|
|
this.Show();
|
|
}
|
|
else
|
|
{
|
|
this.Hide();
|
|
}
|
|
}
|
|
|
|
Show()
|
|
{
|
|
const STEP_SIZE_PIXELS = 50;
|
|
|
|
let zIndex = DialogBox.#zIndexHelper.RegisterForTop(this.floatingWindow.style, "zIndex");
|
|
|
|
if (this.floatingWindow.style.top == "50px" &&
|
|
this.floatingWindow.style.left == "50px")
|
|
{
|
|
this.floatingWindow.style.top = `${STEP_SIZE_PIXELS * zIndex}px`;
|
|
this.floatingWindow.style.left = `${STEP_SIZE_PIXELS * zIndex}px`;
|
|
}
|
|
|
|
this.floatingWindow.style.display = 'flex';
|
|
}
|
|
|
|
Hide()
|
|
{
|
|
DialogBox.#zIndexHelper.RequestTop(this.floatingWindow.style);
|
|
|
|
this.floatingWindow.style.display = 'none';
|
|
}
|
|
|
|
static #EnsureEscapeHandler()
|
|
{
|
|
if (DialogBox.#escapeHandlerSet)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DialogBox.#escapeHandlerSet = true;
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key !== "Escape")
|
|
{
|
|
return;
|
|
}
|
|
|
|
let topMost = null;
|
|
let topZ = Number.NEGATIVE_INFINITY;
|
|
for (const dlg of DialogBox.#instanceList)
|
|
{
|
|
if (!dlg || !dlg.floatingWindow)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (dlg.floatingWindow.style.display === 'none')
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let z = parseInt(dlg.floatingWindow.style.zIndex || "0");
|
|
if (isNaN(z))
|
|
{
|
|
z = 0;
|
|
}
|
|
|
|
if (z >= topZ)
|
|
{
|
|
topZ = z;
|
|
topMost = dlg;
|
|
}
|
|
}
|
|
|
|
if (topMost)
|
|
{
|
|
topMost.Hide();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
}
|
|
|
|
#MakeFloatingWindowFrame()
|
|
{
|
|
this.floatingWindow = document.createElement('div');
|
|
|
|
this.floatingWindow.style.boxSizing = "border-box";
|
|
|
|
this.floatingWindow.style.position = 'fixed';
|
|
this.floatingWindow.style.top = '50px';
|
|
this.floatingWindow.style.left = '50px';
|
|
|
|
this.floatingWindow.style.backgroundColor = '#f0f0f0';
|
|
|
|
this.floatingWindow.style.border = '1px solid black';
|
|
this.floatingWindow.style.borderRadius = '5px';
|
|
|
|
this.floatingWindow.style.boxShadow = '2px 2px 8px black';
|
|
|
|
this.floatingWindow.style.padding = '0px';
|
|
|
|
this.floatingWindow.style.display = 'none'; // Initially hidden
|
|
this.floatingWindow.style.zIndex = 1;
|
|
|
|
this.floatingWindow.style.flexDirection = "column";
|
|
|
|
|
|
return this.floatingWindow;
|
|
}
|
|
|
|
#MakeTopRow()
|
|
{
|
|
// create top row
|
|
this.topRow = document.createElement('div');
|
|
this.topRow.style.boxSizing = "border-box";
|
|
|
|
this.topRow.style.borderBottom = "1px solid black";
|
|
this.topRow.style.borderTopRightRadius = "5px";
|
|
this.topRow.style.borderTopLeftRadius = "5px";
|
|
this.topRow.style.display = "flex";
|
|
this.topRow.style.backgroundColor = "#ff323254";
|
|
|
|
// top row - title bar
|
|
this.titleBar = document.createElement('div');
|
|
this.titleBar.style.boxSizing = "border-box";
|
|
|
|
this.titleBar.style.flexGrow = "1";
|
|
this.titleBar.style.borderRight = "1px solid black";
|
|
this.titleBar.style.borderTopLeftRadius = "5px";
|
|
|
|
this.titleBar.style.padding = "3px";
|
|
this.titleBar.style.backgroundColor = 'rgb(255, 255, 200)';
|
|
this.titleBar.style.cursor = 'move'; // Indicate draggable behavior
|
|
this.titleBar.innerHTML = "Dialog Box";
|
|
this.topRow.appendChild(this.titleBar);
|
|
|
|
// top row - close button
|
|
const closeButton = document.createElement('button');
|
|
closeButton.textContent = 'X';
|
|
// closeButton.style.cursor = 'pointer';
|
|
closeButton.style.border = 'none';
|
|
closeButton.style.backgroundColor = 'rgba(0,0,0,0)'; // transparent
|
|
|
|
this.topRow.appendChild(closeButton);
|
|
|
|
// Close button event handling
|
|
closeButton.addEventListener('click', () => {
|
|
this.Hide();
|
|
});
|
|
|
|
return this.topRow;
|
|
}
|
|
|
|
#MakeBody()
|
|
{
|
|
let dom = document.createElement('div');
|
|
dom.style.boxSizing = "border-box";
|
|
dom.style.padding = "3px";
|
|
dom.style.width = "100%";
|
|
dom.style.flexGrow = "1";
|
|
dom.style.backgroundColor = "rgb(210, 210, 210)";
|
|
|
|
// only show scrollbars if necessary
|
|
// (eg someone manually resizes dialog smaller than content minimum size)
|
|
dom.style.overflowX = "auto";
|
|
dom.style.overflowY = "auto";
|
|
dom.style.scrollbarGutter = "stable";
|
|
|
|
// don't scroll the page, just the div
|
|
let ScrollJustThis = dom => {
|
|
dom.addEventListener('wheel', (e) => {
|
|
const hasVerticalScrollbar = dom.scrollHeight > dom.clientHeight;
|
|
|
|
if (hasVerticalScrollbar)
|
|
{
|
|
e.stopPropagation();
|
|
}
|
|
else
|
|
{
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
};
|
|
|
|
// ScrollJustThis(dom)
|
|
|
|
return dom;
|
|
}
|
|
|
|
#EnableDrag()
|
|
{
|
|
this.floatingWindow.addEventListener('mousedown', (e) => {
|
|
e.stopPropagation();
|
|
|
|
DialogBox.#zIndexHelper.RequestTop(this.floatingWindow.style);
|
|
});
|
|
|
|
this.titleBar.addEventListener('mousedown', (e) => {
|
|
e.stopPropagation();
|
|
|
|
DialogBox.#zIndexHelper.RequestTop(this.floatingWindow.style);
|
|
|
|
this.isDragging = true;
|
|
this.offsetX = e.clientX - this.floatingWindow.getBoundingClientRect().left;
|
|
this.offsetY = e.clientY - this.floatingWindow.getBoundingClientRect().top;
|
|
document.body.style.userSelect = 'none'; // Prevent text selection during drag
|
|
});
|
|
|
|
// Drag the window
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (this.isDragging) {
|
|
// determine viewable area
|
|
let viewableWidth = GetViewableWidthAccountingForScrollbar();
|
|
let viewableHeight = GetViewableHeightAccountingForScrollbar();
|
|
|
|
// prevent mouse from dragging popup outside the viewable
|
|
// area on the left, right, and bottom.
|
|
let cursorX = e.clientX;
|
|
if (cursorX < 0) { cursorX = 0; }
|
|
if (cursorX > viewableWidth) { cursorX = viewableWidth; }
|
|
let cursorY = e.clientY;
|
|
if (cursorY > viewableHeight) { cursorY = viewableHeight; }
|
|
|
|
// don't let the dialog go above the window at all
|
|
let top = cursorY - this.offsetY;
|
|
let left = cursorX - this.offsetX;
|
|
if (top < 0) { top = 0; }
|
|
|
|
// apply
|
|
this.floatingWindow.style.top = `${top}px`;
|
|
this.floatingWindow.style.left = `${left}px`;
|
|
}
|
|
});
|
|
|
|
// Stop dragging
|
|
document.addEventListener('mouseup', () => {
|
|
if (this.isDragging) {
|
|
this.isDragging = false;
|
|
document.body.style.userSelect = ''; // Re-enable text selection
|
|
}
|
|
});
|
|
}
|
|
|
|
#MakeUI()
|
|
{
|
|
let frame = this.#MakeFloatingWindowFrame();
|
|
let frameTopRow = this.#MakeTopRow();
|
|
this.frameBody = this.#MakeBody();
|
|
|
|
this.frameBody.marginTop = "2px";
|
|
|
|
frame.appendChild(frameTopRow);
|
|
frame.appendChild(this.frameBody);
|
|
|
|
// don't let the page scroll when you hover the popup
|
|
// (scrollable content section handled separately)
|
|
// frame.addEventListener('wheel', (e) => {
|
|
// e.preventDefault();
|
|
// });
|
|
|
|
this.#EnableDrag();
|
|
|
|
return this.floatingWindow;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export class CollapsableTitleBox
|
|
{
|
|
constructor()
|
|
{
|
|
this.ui = this.#MakeUI();
|
|
this.#SetUpEvents();
|
|
}
|
|
|
|
GetUI()
|
|
{
|
|
return this.ui;
|
|
}
|
|
|
|
SetTitle(title)
|
|
{
|
|
this.titleBar.innerHTML = title;
|
|
}
|
|
|
|
GetContentContainer()
|
|
{
|
|
return this.box;
|
|
}
|
|
|
|
SetMinWidth(minWidth)
|
|
{
|
|
this.ui.style.minWidth = minWidth;
|
|
}
|
|
|
|
ToggleShowHide()
|
|
{
|
|
if (this.box.style.display === 'none')
|
|
{
|
|
this.Show();
|
|
}
|
|
else
|
|
{
|
|
this.Hide();
|
|
}
|
|
}
|
|
|
|
Show()
|
|
{
|
|
this.box.style.display = 'flex';
|
|
}
|
|
|
|
Hide()
|
|
{
|
|
this.box.style.display = 'none';
|
|
}
|
|
|
|
#SetUpEvents()
|
|
{
|
|
this.titleBar.addEventListener('click', () => {
|
|
this.ToggleShowHide();
|
|
});
|
|
}
|
|
|
|
#MakeUI()
|
|
{
|
|
// entire structure
|
|
this.ui = document.createElement('div');
|
|
this.ui.style.boxSizing = "border-box";
|
|
this.ui.style.backgroundColor = "white";
|
|
this.ui.style.border = "1px solid grey";
|
|
|
|
// user reads this, click to hide/unhide
|
|
this.titleBar = document.createElement('div');
|
|
this.titleBar.style.boxSizing = "border-box";
|
|
this.titleBar.style.padding = "3px";
|
|
this.titleBar.style.backgroundColor = "rgb(240, 240, 240)";
|
|
// this.titleBar.style.backgroundColor = "rgb(200, 200, 255)";
|
|
this.titleBar.style.userSelect = "none";
|
|
this.titleBar.style.cursor = "pointer";
|
|
this.titleBar.innerHTML = "Title Bar";
|
|
|
|
// user content goes here
|
|
this.box = document.createElement('div');
|
|
this.box.style.boxSizing = "border-box";
|
|
this.box.style.padding = "5px";
|
|
this.box.style.boxShadow = "1px 1px 5px #555 inset";
|
|
this.box.style.overflowX = "auto";
|
|
this.box.style.overflowY = "auto";
|
|
this.box.style.display = 'none'; // initially hidden
|
|
|
|
// pack
|
|
this.ui.appendChild(this.titleBar);
|
|
this.ui.appendChild(this.box);
|
|
|
|
return this.ui;
|
|
}
|
|
}
|
|
|
|
|
|
export class RadioCheckbox
|
|
{
|
|
constructor(name)
|
|
{
|
|
this.name = name;
|
|
this.ui = this.#MakeUI();
|
|
|
|
this.inputList = [];
|
|
|
|
this.fnOnChange = (val) => {};
|
|
}
|
|
|
|
AddOption(labelText, value, checked)
|
|
{
|
|
// create input
|
|
let input = document.createElement('input');
|
|
input.type = "radio";
|
|
input.name = this.name;
|
|
input.value = value;
|
|
if (checked)
|
|
{
|
|
input.checked = true;
|
|
}
|
|
this.inputList.push(input);
|
|
|
|
// set up label
|
|
let label = document.createElement('label');
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(` ${labelText}`));
|
|
|
|
// add to container
|
|
if (this.inputList.length != 1)
|
|
{
|
|
this.ui.appendChild(document.createTextNode(' '));
|
|
}
|
|
this.ui.appendChild(label);
|
|
|
|
// set up events
|
|
input.addEventListener('change', (e) => {
|
|
this.fnOnChange(e.target.value);
|
|
});
|
|
}
|
|
|
|
SetOnChangeCallback(fn)
|
|
{
|
|
this.fnOnChange = fn;
|
|
}
|
|
|
|
Trigger()
|
|
{
|
|
for (let input of this.inputList)
|
|
{
|
|
if (input.checked)
|
|
{
|
|
this.fnOnChange(input.value);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
GetUI()
|
|
{
|
|
return this.ui;
|
|
}
|
|
|
|
#MakeUI()
|
|
{
|
|
let ui = document.createElement('span');
|
|
|
|
return ui;
|
|
}
|
|
}
|
|
|
|
|
|
// write through and read-through cache stored persistently
|
|
export class RadioCheckboxPersistent
|
|
extends RadioCheckbox
|
|
{
|
|
constructor(name)
|
|
{
|
|
super(name);
|
|
|
|
this.val = null;
|
|
|
|
// cache currently-stored value
|
|
if (localStorage.getItem(this.name) != null)
|
|
{
|
|
this.val = localStorage.getItem(this.name);
|
|
}
|
|
}
|
|
|
|
// add option except checked is just a suggestion.
|
|
// if no prior value set, let suggestion take effect.
|
|
// if prior value set, prior value rules.
|
|
AddOption(labelText, value, checkedSuggestion)
|
|
{
|
|
let checked = checkedSuggestion;
|
|
|
|
if (this.val == null)
|
|
{
|
|
// let it happen
|
|
}
|
|
else
|
|
{
|
|
checked = this.val == value;
|
|
}
|
|
|
|
super.AddOption(labelText, value, checked);
|
|
|
|
// cache and write through
|
|
if (checked)
|
|
{
|
|
this.val = value;
|
|
localStorage.setItem(this.name, this.val);
|
|
}
|
|
}
|
|
|
|
SetOnChangeCallback(fn)
|
|
{
|
|
super.SetOnChangeCallback((val) => {
|
|
// capture the new value before passing back
|
|
this.val = val;
|
|
localStorage.setItem(this.name, this.val);
|
|
|
|
// callback
|
|
fn(val);
|
|
});
|
|
}
|
|
}
|