/* 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); }); } }