diff --git a/renderer/src/modules/domtools.js b/renderer/src/modules/domtools.js index f66d8551..bd6d1609 100644 --- a/renderer/src/modules/domtools.js +++ b/renderer/src/modules/domtools.js @@ -39,10 +39,34 @@ export default class DOMTools { + /** Document/window width */ + static get screenWidth() {return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);} + + /** Document/window height */ + static get screenHeight() {return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);} + static escapeID(id) { return id.replace(/^[^a-z]+|[^\w-]+/gi, "-"); } + // https://javascript.info/js-animation + static animate({timing = _ => _, update, duration}) { + const start = performance.now(); + + requestAnimationFrame(function animate(time) { + // timeFraction goes from 0 to 1 + let timeFraction = (time - start) / duration; + if (timeFraction > 1) timeFraction = 1; + + // calculate the current animation state + const progress = timing(timeFraction); + + update(progress); // draw it + + if (timeFraction < 1) requestAnimationFrame(animate); + }); + } + /** * Adds a style to the document. * @param {string} id - identifier to use as the element id @@ -87,27 +111,6 @@ export default class DOMTools { const element = document.getElementById(id); if (element) element.remove(); } - - // https://javascript.info/js-animation - static animate({timing = _ => _, update, duration}) { - const start = performance.now(); - - requestAnimationFrame(function animate(time) { - // timeFraction goes from 0 to 1 - let timeFraction = (time - start) / duration; - if (timeFraction > 1) timeFraction = 1; - - // calculate the current animation state - const progress = timing(timeFraction); - - update(progress); // draw it - - if (timeFraction < 1) { - requestAnimationFrame(animate); - } - - }); - } /** * This is my shit version of not having to use `$` from jQuery. Meaning diff --git a/renderer/src/modules/pluginapi.js b/renderer/src/modules/pluginapi.js index 4d71868a..7cace1ee 100644 --- a/renderer/src/modules/pluginapi.js +++ b/renderer/src/modules/pluginapi.js @@ -14,6 +14,7 @@ import Logger from "common/logger"; import Patcher from "./patcher"; import Emotes from "../builtins/emotes/emotes"; import ipc from "./ipc"; +import Tooltip from "../ui/tooltip"; /** * `BdApi` is a globally (`window.BdApi`) accessible object for use by plugins and developers to make their lives easier. @@ -162,6 +163,22 @@ BdApi.showToast = function(content, options = {}) { return Notices.show(content, options); }; +/** + * Creates a tooltip to automatically show on hover. + * + * @param {HTMLElement} node - DOM node to monitor and show the tooltip on + * @param {string|HTMLElement} content - string to show in the tooltip + * @param {object} options - additional options for the tooltip + * @param {"primary"|"info"|"success"|"warn"|"danger"} [options.style="primary"] - correlates to the discord styling/colors + * @param {"top"|"right"|"bottom"|"left"} [options.side="top"] - can be any of top, right, bottom, left + * @param {boolean} [options.preventFlip=false] - prevents moving the tooltip to the opposite side if it is too big or goes offscreen + * @param {boolean} [options.disabled=false] - whether the tooltip should be disabled from showing on hover + * @returns new Tooltip + */ + BdApi.createTooltip = function(node, content, options = {}) { + return Tooltip.create(node, content, options); +}; + /** * Finds a webpack module using a filter. * diff --git a/renderer/src/styles/ui/tooltip.css b/renderer/src/styles/ui/tooltip.css new file mode 100644 index 00000000..f6019d41 --- /dev/null +++ b/renderer/src/styles/ui/tooltip.css @@ -0,0 +1,104 @@ +.bd-layer { + position: absolute; +} + +.bd-tooltip { + position: relative; + border-radius: 5px; + font-weight: 500; + font-size: 14px; + line-height: 16px; + max-width: 190px; + box-sizing: border-box; + word-wrap: break-word; + z-index: 1002; + will-change: opacity, transform; + box-shadow: var(--elevation-high); + color: var(--header-primary); +} + +.bd-tooltip-content { + padding: 8px 12px; + overflow: hidden; + } + +.bd-tooltip-pointer { + pointer-events: none; + width: 0; + height: 0; + border: 5px solid transparent; +} + +.bd-tooltip-primary { + background-color: var(--background-floating); + color: var(--text-normal); +} + +.bd-tooltip-primary .bd-tooltip-pointer { + border-top-color: var(--background-floating); +} + +.bd-tooltip-info { + background-color: #4A90E2; +} + +.bd-tooltip-info .bd-tooltip-pointer { + border-top-color: #4A90E2; +} + +.bd-tooltip-success { + background-color: #43B581; +} + +.bd-tooltip-success .bd-tooltip-pointer { + border-top-color: #43B581; +} + +.bd-tooltip-danger { + background-color: #F04747; +} + +.bd-tooltip-danger .bd-tooltip-pointer { + border-top-color: #F04747; +} + +.bd-tooltip-warn { + background-color: #FFA600; +} + +.bd-tooltip-warn .bd-tooltip-pointer { + border-top-color: #FFA600; +} + +.bd-tooltip-top .bd-tooltip-pointer { + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; +} + +.bd-tooltip-bottom .bd-tooltip-pointer { + position: absolute; + bottom: 100%; + left: 50%; + margin-left: -5px; + transform: rotate(180deg); +} + +.bd-tooltip-right .bd-tooltip-pointer { + position: absolute; + right: 100%; + top: 50%; + margin-top: -5px; + border-left-width: 5px; + transform: rotate(90deg); +} + +.bd-tooltip-left .bd-tooltip-pointer { + position: absolute; + left: 100%; + top: 50%; + margin-top: -5px; + border-left-width: 5px; + transform: rotate(270deg); +} \ No newline at end of file diff --git a/renderer/src/ui/tooltip.js b/renderer/src/ui/tooltip.js new file mode 100644 index 00000000..5f30ca10 --- /dev/null +++ b/renderer/src/ui/tooltip.js @@ -0,0 +1,164 @@ +import Logger from "common/logger"; +import {DOM} from "modules"; + + +const toPx = function(value) { + return `${value}px`; +}; + +const styles = ["primary", "info", "success", "warn", "danger"]; +const sides = ["top", "right", "bottom", "left"]; + +export default class Tooltip { + /** + * + * @constructor + * @param {HTMLElement} node - DOM node to monitor and show the tooltip on + * @param {string|HTMLElement} tip - string to show in the tooltip + * @param {object} options - additional options for the tooltip + * @param {"primary"|"info"|"success"|"warn"|"danger"} [options.style="primary"] - correlates to the discord styling/colors + * @param {"top"|"right"|"bottom"|"left"} [options.side="top"] - can be any of top, right, bottom, left + * @param {boolean} [options.preventFlip=false] - prevents moving the tooltip to the opposite side if it is too big or goes offscreen + * @param {boolean} [options.disabled=false] - whether the tooltip should be disabled from showing on hover + */ + constructor(node, text, options = {}) { + const {style = "primary", side = "top", preventFlip = false, disabled = false} = options; + this.node = node; + this.label = text; + this.style = style.toLowerCase(); + this.side = side.toLowerCase(); + this.preventFlip = preventFlip; + this.disabled = disabled; + this.active = false; + + if (!sides.includes(this.side)) return Logger.err("Tooltip", `Side ${this.side} does not exist.`); + if (!styles.includes(this.style)) return Logger.err("Tooltip", `Style ${this.style} does not exist.`); + + this.element = DOM.createElement(`
`); + this.tooltipElement = DOM.createElement(`
`); + this.tooltipElement.classList.add(`bd-tooltip-${this.style}`); + + this.labelElement = this.tooltipElement.childNodes[1]; + if (text instanceof HTMLElement) this.labelElement.append(text); + else this.labelElement.textContent = text; + + this.element.append(this.tooltipElement); + + this.node.addEventListener("mouseenter", () => { + if (this.disabled) return; + this.show(); + }); + + this.node.addEventListener("mouseleave", () => { + this.hide(); + }); + } + + /** Alias for the constructor */ + static create(node, text, options = {}) {return new Tooltip(node, text, options);} + + /** Container where the tooltip will be appended. */ + get container() {return document.querySelector(`#app-mount`);} + /** Boolean representing if the tooltip will fit on screen above the element */ + get canShowAbove() {return this.node.getBoundingClientRect().top - this.element.offsetHeight >= 0;} + /** Boolean representing if the tooltip will fit on screen below the element */ + get canShowBelow() {return this.node.getBoundingClientRect().top + this.node.offsetHeight + this.element.offsetHeight <= DOM.screenHeight;} + /** Boolean representing if the tooltip will fit on screen to the left of the element */ + get canShowLeft() {return this.node.getBoundingClientRect().left - this.element.offsetWidth >= 0;} + /** Boolean representing if the tooltip will fit on screen to the right of the element */ + get canShowRight() {return this.node.getBoundingClientRect().left + this.node.offsetWidth + this.element.offsetWidth <= DOM.screenWidth;} + + /** Hides the tooltip. Automatically called on mouseleave. */ + hide() { + /** Don't rehide if already inactive */ + if (!this.active) return; + this.active = false; + this.element.remove(); + } + + /** Shows the tooltip. Automatically called on mouseenter. Will attempt to flip if position was wrong. */ + show() { + /** Don't reshow if already active */ + if (this.active) return; + this.active = true; + this.labelElement.textContent = this.label; + this.container.append(this.element); + + if (this.side == "top") { + if (this.canShowAbove || (!this.canShowAbove && this.preventFlip)) this.showAbove(); + else this.showBelow(); + } + + if (this.side == "bottom") { + if (this.canShowBelow || (!this.canShowBelow && this.preventFlip)) this.showBelow(); + else this.showAbove(); + } + + if (this.side == "left") { + if (this.canShowLeft || (!this.canShowLeft && this.preventFlip)) this.showLeft(); + else this.showRight(); + } + + if (this.side == "right") { + if (this.canShowRight || (!this.canShowRight && this.preventFlip)) this.showRight(); + else this.showLeft(); + } + + /** Do not create a new observer each time if one already exists! */ + if (this.observer) return; + /** Use an observer in show otherwise you'll cause unclosable tooltips */ + this.observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + const nodes = Array.from(mutation.removedNodes); + const directMatch = nodes.indexOf(this.node) > -1; + const parentMatch = nodes.some(parent => parent.contains(this.node)); + if (directMatch || parentMatch) { + this.hide(); + this.observer.disconnect(); + } + }); + }); + + this.observer.observe(document.body, {subtree: true, childList: true}); + } + + /** Force showing the tooltip above the node. */ + showAbove() { + this.tooltipElement.classList.add("bd-tooltip-top"); + this.element.style.setProperty("top", toPx(this.node.getBoundingClientRect().top - this.element.offsetHeight - 10)); + this.centerHorizontally(); + } + + /** Force showing the tooltip below the node. */ + showBelow() { + this.tooltipElement.classList.add("bd-tooltip-bottom"); + this.element.style.setProperty("top", toPx(this.node.getBoundingClientRect().top + this.node.offsetHeight + 10)); + this.centerHorizontally(); + } + + /** Force showing the tooltip to the left of the node. */ + showLeft() { + this.tooltipElement.classList.add("bd-tooltip-left"); + this.element.style.setProperty("left", toPx(this.node.getBoundingClientRect().left - this.element.offsetWidth - 10)); + this.centerVertically(); + } + + /** Force showing the tooltip to the right of the node. */ + showRight() { + this.tooltipElement.classList.add("bd-tooltip-right"); + this.element.style.setProperty("left", toPx(this.node.getBoundingClientRect().left + this.node.offsetWidth + 10)); + this.centerVertically(); + } + + centerHorizontally() { + const nodecenter = this.node.getBoundingClientRect().left + (this.node.offsetWidth / 2); + this.element.style.setProperty("left", toPx(nodecenter - (this.element.offsetWidth / 2))); + } + + centerVertically() { + const nodecenter = this.node.getBoundingClientRect().top + (this.node.offsetHeight / 2); + this.element.style.setProperty("top", toPx(nodecenter - (this.element.offsetHeight / 2))); + } +} + +window.Tooltip = Tooltip; \ No newline at end of file