Files
2025-01-04 00:34:03 +01:00

498 lines
18 KiB
JavaScript

/**
* A singleton Tooltip Manager class responsible for rendering and positioning a dynamic tooltip element which is
* accessible as `game.tooltip`.
*
* @see {@link Game.tooltip}
*
* @example API Usage
* ```js
* game.tooltip.activate(htmlElement, {text: "Some tooltip text", direction: "UP"});
* game.tooltip.deactivate();
* ```
*
* @example HTML Usage
* ```html
* <span data-tooltip="Some Tooltip" data-tooltip-direction="LEFT">I have a tooltip</span>
* <ol data-tooltip-direction="RIGHT">
* <li data-tooltip="The First One">One</li>
* <li data-tooltip="The Second One">Two</li>
* <li data-tooltip="The Third One">Three</li>
* </ol>
* ```
*/
class TooltipManager {
/**
* A cached reference to the global tooltip element
* @type {HTMLElement}
*/
tooltip = document.getElementById("tooltip");
/**
* A reference to the HTML element which is currently tool-tipped, if any.
* @type {HTMLElement|null}
*/
element = null;
/**
* An amount of margin which is used to offset tooltips from their anchored element.
* @type {number}
*/
static TOOLTIP_MARGIN_PX = 5;
/**
* The number of milliseconds delay which activates a tooltip on a "long hover".
* @type {number}
*/
static TOOLTIP_ACTIVATION_MS = 500;
/**
* The directions in which a tooltip can extend, relative to its tool-tipped element.
* @enum {string}
*/
static TOOLTIP_DIRECTIONS = {
UP: "UP",
DOWN: "DOWN",
LEFT: "LEFT",
RIGHT: "RIGHT",
CENTER: "CENTER"
};
/**
* The number of pixels buffer around a locked tooltip zone before they should be dismissed.
* @type {number}
*/
static LOCKED_TOOLTIP_BUFFER_PX = 50;
/**
* Is the tooltip currently active?
* @type {boolean}
*/
#active = false;
/**
* A reference to a window timeout function when an element is activated.
*/
#activationTimeout;
/**
* A reference to a window timeout function when an element is deactivated.
*/
#deactivationTimeout;
/**
* An element which is pending tooltip activation if hover is sustained
* @type {HTMLElement|null}
*/
#pending;
/**
* Maintain state about active locked tooltips in order to perform appropriate automatic dismissal.
* @type {{elements: Set<HTMLElement>, boundingBox: Rectangle}}
*/
#locked = {
elements: new Set(),
boundingBox: {}
};
/* -------------------------------------------- */
/**
* Activate interactivity by listening for hover events on HTML elements which have a data-tooltip defined.
*/
activateEventListeners() {
document.body.addEventListener("pointerenter", this.#onActivate.bind(this), true);
document.body.addEventListener("pointerleave", this.#onDeactivate.bind(this), true);
document.body.addEventListener("pointerup", this._onLockTooltip.bind(this), true);
document.body.addEventListener("pointermove", this.#testLockedTooltipProximity.bind(this), {
capture: true,
passive: true
});
}
/* -------------------------------------------- */
/**
* Handle hover events which activate a tooltipped element.
* @param {PointerEvent} event The initiating pointerenter event
*/
#onActivate(event) {
if ( Tour.tourInProgress ) return; // Don't activate tooltips during a tour
const element = event.target;
if ( element.closest(".editor-content.ProseMirror") ) return; // Don't activate tooltips inside text editors.
if ( !element.dataset.tooltip ) {
// Check if the element has moved out from underneath the cursor and pointerenter has fired on a non-child of the
// tooltipped element.
if ( this.#active && !this.element.contains(element) ) this.#startDeactivation();
return;
}
// Don't activate tooltips if the element contains an active context menu or is in a matching link tooltip
if ( element.matches("#context-menu") || element.querySelector("#context-menu") ) return;
// If the tooltip is currently active, we can move it to a new element immediately
if ( this.#active ) {
this.activate(element);
return;
}
// Clear any existing deactivation workflow
this.#clearDeactivation();
// Delay activation to determine user intent
this.#pending = element;
this.#activationTimeout = window.setTimeout(() => {
this.#activationTimeout = null;
if ( this.#pending ) this.activate(this.#pending);
}, this.constructor.TOOLTIP_ACTIVATION_MS);
}
/* -------------------------------------------- */
/**
* Handle hover events which deactivate a tooltipped element.
* @param {PointerEvent} event The initiating pointerleave event
*/
#onDeactivate(event) {
if ( event.target !== (this.element ?? this.#pending) ) return;
const parent = event.target.parentElement.closest("[data-tooltip]");
if ( parent ) this.activate(parent);
else this.#startDeactivation();
}
/* -------------------------------------------- */
/**
* Start the deactivation process.
*/
#startDeactivation() {
if ( this.#deactivationTimeout ) return;
// Clear any existing activation workflow
this.clearPending();
// Delay deactivation to confirm whether some new element is now pending
this.#deactivationTimeout = window.setTimeout(() => {
this.#deactivationTimeout = null;
if ( !this.#pending ) this.deactivate();
}, this.constructor.TOOLTIP_ACTIVATION_MS);
}
/* -------------------------------------------- */
/**
* Clear any existing deactivation workflow.
*/
#clearDeactivation() {
window.clearTimeout(this.#deactivationTimeout);
this.#deactivationTimeout = null;
}
/* -------------------------------------------- */
/**
* Activate the tooltip for a hovered HTML element which defines a tooltip localization key.
* @param {HTMLElement} element The HTML element being hovered.
* @param {object} [options={}] Additional options which can override tooltip behavior.
* @param {string} [options.text] Explicit tooltip text to display. If this is not provided the tooltip text is
* acquired from the elements data-tooltip attribute. This text will be
* automatically localized
* @param {TooltipManager.TOOLTIP_DIRECTIONS} [options.direction] An explicit tooltip expansion direction. If this
* is not provided the direction is acquired from the data-tooltip-direction
* attribute of the element or one of its parents.
* @param {string} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated
* tooltip. If this is not provided, the CSS classes are acquired from the
* data-tooltip-class attribute of the element or one of its parents.
* @param {boolean} [options.locked] An optional boolean to lock the tooltip after creation. Defaults to false.
* @param {HTMLElement} [options.content] Explicit HTML content to inject into the tooltip rather than using tooltip
* text.
*/
activate(element, {text, direction, cssClass, locked=false, content}={}) {
if ( text && content ) throw new Error("Cannot provide both text and content options to TooltipManager#activate.");
// Deactivate currently active element
this.deactivate();
// Check if the element still exists in the DOM.
if ( !document.body.contains(element) ) return;
// Mark the new element as active
this.#active = true;
this.element = element;
element.setAttribute("aria-describedby", "tooltip");
if ( content ) {
this.tooltip.innerHTML = ""; // Clear existing content.
this.tooltip.appendChild(content);
}
else this.tooltip.innerHTML = text || game.i18n.localize(element.dataset.tooltip);
// Activate display of the tooltip
this.tooltip.removeAttribute("class");
this.tooltip.classList.add("active");
cssClass ??= element.closest("[data-tooltip-class]")?.dataset.tooltipClass;
if ( cssClass ) this.tooltip.classList.add(...cssClass.split(" "));
// Set tooltip position
direction ??= element.closest("[data-tooltip-direction]")?.dataset.tooltipDirection;
if ( !direction ) direction = this._determineDirection();
this._setAnchor(direction);
if ( locked || element.dataset.hasOwnProperty("locked") ) this.lockTooltip();
}
/* -------------------------------------------- */
/**
* Deactivate the tooltip from a previously hovered HTML element.
*/
deactivate() {
// Deactivate display of the tooltip
this.#active = false;
this.tooltip.classList.remove("active");
// Clear any existing (de)activation workflow
this.clearPending();
this.#clearDeactivation();
// Update the tooltipped element
if ( !this.element ) return;
this.element.removeAttribute("aria-describedby");
this.element = null;
}
/* -------------------------------------------- */
/**
* Clear any pending activation workflow.
* @internal
*/
clearPending() {
window.clearTimeout(this.#activationTimeout);
this.#pending = this.#activationTimeout = null;
}
/* -------------------------------------------- */
/**
* Lock the current tooltip.
* @returns {HTMLElement}
*/
lockTooltip() {
const clone = this.tooltip.cloneNode(false);
// Steal the content from the original tooltip rather than cloning it, so that listeners are preserved.
while ( this.tooltip.firstChild ) clone.appendChild(this.tooltip.firstChild);
clone.removeAttribute("id");
clone.classList.add("locked-tooltip", "active");
document.body.appendChild(clone);
this.deactivate();
clone.addEventListener("contextmenu", this._onLockedTooltipDismiss.bind(this));
this.#locked.elements.add(clone);
// If the tooltip's contents were injected via setting innerHTML, then immediately requesting the bounding box will
// return incorrect values as the browser has not had a chance to reflow yet. For that reason we defer computing the
// bounding box until the next frame.
requestAnimationFrame(() => this.#computeLockedBoundingBox());
return clone;
}
/* -------------------------------------------- */
/**
* Handle a request to lock the current tooltip.
* @param {MouseEvent} event The click event.
* @protected
*/
_onLockTooltip(event) {
if ( (event.button !== 1) || !this.#active || Tour.tourInProgress ) return;
event.preventDefault();
this.lockTooltip();
}
/* -------------------------------------------- */
/**
* Handle dismissing a locked tooltip.
* @param {MouseEvent} event The click event.
* @protected
*/
_onLockedTooltipDismiss(event) {
event.preventDefault();
const target = event.currentTarget;
this.dismissLockedTooltip(target);
}
/* -------------------------------------------- */
/**
* Dismiss a given locked tooltip.
* @param {HTMLElement} element The locked tooltip to dismiss.
*/
dismissLockedTooltip(element) {
this.#locked.elements.delete(element);
element.remove();
this.#computeLockedBoundingBox();
}
/* -------------------------------------------- */
/**
* Compute the unified bounding box from the set of locked tooltip elements.
*/
#computeLockedBoundingBox() {
let bb = null;
for ( const element of this.#locked.elements.values() ) {
const {x, y, width, height} = element.getBoundingClientRect();
const rect = new PIXI.Rectangle(x, y, width, height);
if ( bb ) bb.enlarge(rect);
else bb = rect;
}
this.#locked.boundingBox = bb;
}
/* -------------------------------------------- */
/**
* Check whether the user is moving away from the locked tooltips and dismiss them if so.
* @param {MouseEvent} event The mouse move event.
*/
#testLockedTooltipProximity(event) {
if ( !this.#locked.elements.size ) return;
const {clientX: x, clientY: y, movementX, movementY} = event;
const buffer = this.#locked.boundingBox?.clone?.().pad(this.constructor.LOCKED_TOOLTIP_BUFFER_PX);
// If the cursor is close enough to the bounding box, or we have no movement information, do nothing.
if ( !buffer || buffer.contains(x, y) || !Number.isFinite(movementX) || !Number.isFinite(movementY) ) return;
// Otherwise, check if the cursor is moving away from the tooltip, and dismiss it if so.
if ( ((movementX > 0) && (x > buffer.right))
|| ((movementX < 0) && (x < buffer.x))
|| ((movementY > 0) && (y > buffer.bottom))
|| ((movementY < 0) && (y < buffer.y)) ) this.dismissLockedTooltips();
}
/* -------------------------------------------- */
/**
* Dismiss the set of active locked tooltips.
*/
dismissLockedTooltips() {
for ( const element of this.#locked.elements.values() ) {
element.remove();
}
this.#locked.elements = new Set();
}
/* -------------------------------------------- */
/**
* Create a locked tooltip at the given position.
* @param {object} position A position object with coordinates for where the tooltip should be placed
* @param {string} position.top Explicit top position for the tooltip
* @param {string} position.right Explicit right position for the tooltip
* @param {string} position.bottom Explicit bottom position for the tooltip
* @param {string} position.left Explicit left position for the tooltip
* @param {string} text Explicit tooltip text or HTML to display.
* @param {object} [options={}] Additional options which can override tooltip behavior.
* @param {array} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated
* tooltip.
* @returns {HTMLElement}
*/
createLockedTooltip(position, text, {cssClass}={}) {
this.#clearDeactivation();
this.tooltip.innerHTML = text;
this.tooltip.style.top = position.top || "";
this.tooltip.style.right = position.right || "";
this.tooltip.style.bottom = position.bottom || "";
this.tooltip.style.left = position.left || "";
const clone = this.lockTooltip();
if ( cssClass ) clone.classList.add(...cssClass.split(" "));
return clone;
}
/* -------------------------------------------- */
/**
* If an explicit tooltip expansion direction was not specified, figure out a valid direction based on the bounds
* of the target element and the screen.
* @protected
*/
_determineDirection() {
const pos = this.element.getBoundingClientRect();
const dirs = this.constructor.TOOLTIP_DIRECTIONS;
return dirs[pos.y + this.tooltip.offsetHeight > window.innerHeight ? "UP" : "DOWN"];
}
/* -------------------------------------------- */
/**
* Set tooltip position relative to an HTML element using an explicitly provided data-tooltip-direction.
* @param {TooltipManager.TOOLTIP_DIRECTIONS} direction The tooltip expansion direction specified by the element
* or a parent element.
* @protected
*/
_setAnchor(direction) {
const directions = this.constructor.TOOLTIP_DIRECTIONS;
const pad = this.constructor.TOOLTIP_MARGIN_PX;
const pos = this.element.getBoundingClientRect();
let style = {};
switch ( direction ) {
case directions.DOWN:
style.textAlign = "center";
style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
style.top = pos.bottom + pad;
break;
case directions.LEFT:
style.textAlign = "left";
style.right = window.innerWidth - pos.left + pad;
style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
break;
case directions.RIGHT:
style.textAlign = "right";
style.left = pos.right + pad;
style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
break;
case directions.UP:
style.textAlign = "center";
style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
style.bottom = window.innerHeight - pos.top + pad;
break;
case directions.CENTER:
style.textAlign = "center";
style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
break;
}
return this._setStyle(style);
}
/* -------------------------------------------- */
/**
* Apply inline styling rules to the tooltip for positioning and text alignment.
* @param {object} [position={}] An object of positioning data, supporting top, right, bottom, left, and textAlign
* @protected
*/
_setStyle(position={}) {
const pad = this.constructor.TOOLTIP_MARGIN_PX;
position = {top: null, right: null, bottom: null, left: null, textAlign: "left", ...position};
const style = this.tooltip.style;
// Left or Right
const maxW = window.innerWidth - this.tooltip.offsetWidth;
if ( position.left ) position.left = Math.clamp(position.left, pad, maxW - pad);
if ( position.right ) position.right = Math.clamp(position.right, pad, maxW - pad);
// Top or Bottom
const maxH = window.innerHeight - this.tooltip.offsetHeight;
if ( position.top ) position.top = Math.clamp(position.top, pad, maxH - pad);
if ( position.bottom ) position.bottom = Math.clamp(position.bottom, pad, maxH - pad);
// Assign styles
for ( let k of ["top", "right", "bottom", "left"] ) {
const v = position[k];
style[k] = v ? `${v}px` : null;
}
this.tooltip.classList.remove(...["center", "left", "right"].map(dir => `text-${dir}`));
this.tooltip.classList.add(`text-${position.textAlign}`);
}
}