498 lines
18 KiB
JavaScript
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}`);
|
|
}
|
|
}
|