From 10abcf7105d6fd04ffd4dbb3a983833bf7394f4c Mon Sep 17 00:00:00 2001 From: Trond A Ekseth Date: Thu, 19 Dec 2024 20:35:10 +0100 Subject: [PATCH] Add right-click context menu for ADV/DIS rolls --- CHANGELOG.md | 6 +- src/characters.js | 2 + src/clients/browser/manifest.shared.json | 2 +- src/clients/symbiote/manifest.json | 1 + src/contextmenu.js | 124 +++++++++++++++++++++++ src/css/contextmenu.css | 69 +++++++++++++ src/options.html | 5 + src/options.js | 33 ++++++ src/utils/storage.js | 1 + src/utils/talespire.js | 50 ++++----- 10 files changed, 268 insertions(+), 25 deletions(-) create mode 100644 src/contextmenu.js create mode 100644 src/css/contextmenu.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c14151..da2d8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog -## 0.16.4 (Unreleased) +## 0.17.0 (Unreleased) ### Bug fixes * Dice buttons will no longer be added to item names in the sidebar heading. +### Enhancements + + * It's now possible to right-click dice buttons to roll with Advantage or Disadvantage. + ## 0.16.3 (2024-10-25) ### Bug fixes diff --git a/src/characters.js b/src/characters.js index ed2a311..ed29475 100644 --- a/src/characters.js +++ b/src/characters.js @@ -1,3 +1,4 @@ +import { injectContextMenu } from "~/contextmenu"; import svgLogo from "~/icons/icon.svg"; import { customMod } from "~/mods"; import { injectThemeStyle } from "~/themes"; @@ -239,6 +240,7 @@ export const characterAppWatcher = (showOptionsButton = false) => { getCharacterAbilities(); injectThemeStyle(); + await injectContextMenu(); observer.disconnect(); diff --git a/src/clients/browser/manifest.shared.json b/src/clients/browser/manifest.shared.json index d6ad061..bb5e97d 100644 --- a/src/clients/browser/manifest.shared.json +++ b/src/clients/browser/manifest.shared.json @@ -12,7 +12,7 @@ "matches": ["*://*.dndbeyond.com/characters/*"], "run_at": "document_end", "js": ["characters.js"], - "css": ["css/dialog.css", "css/styles.css"] + "css": ["css/dialog.css", "css/styles.css", "css/contextmenu.css"] }, { "matches": ["*://*.dndbeyond.com/monsters*"], diff --git a/src/clients/symbiote/manifest.json b/src/clients/symbiote/manifest.json index ce0b5ec..30b9149 100644 --- a/src/clients/symbiote/manifest.json +++ b/src/clients/symbiote/manifest.json @@ -33,6 +33,7 @@ "fonts", "/characters.js", "/css/dialog.css", + "/css/contextmenu.css", "/css/styles.css" ] } diff --git a/src/contextmenu.js b/src/contextmenu.js new file mode 100644 index 0000000..8b4625f --- /dev/null +++ b/src/contextmenu.js @@ -0,0 +1,124 @@ +import svgLogo from "~/icons/icon.svg"; +import { getOptions } from "~/utils/storage"; +import { triggerTalespire } from "~/utils/talespire"; + +const removeAllMenus = () => { + for (const activeMenu of document.querySelectorAll( + ".tales-beyond-extension-contextmenu", + )) { + activeMenu.remove(); + } + + window.removeEventListener("click", detectLightDismiss); + window.removeEventListener("scroll", removeAllMenus); +}; + +const detectLightDismiss = (event) => { + const activeMenu = Array.from( + document.querySelectorAll(".tales-beyond-extension-contextmenu"), + ); + + const clickedMenu = activeMenu.some((elem) => elem.contains(event.target)); + if (!clickedMenu) { + removeAllMenus(); + } +}; + +const setupListeners = (button, contextmenu) => { + const label = button.dataset.tsLabel; + const dice = button.dataset.tsDice; + + const action = (labelSuffix) => () => { + if (labelSuffix) { + const name = label ? `${label} (${labelSuffix})` : labelSuffix; + triggerTalespire(name, dice, true); + } else { + triggerTalespire(label, dice); + } + + removeAllMenus(); + }; + + const [adv, flat, dis] = contextmenu.querySelectorAll(".item"); + adv.addEventListener("click", action("ADV")); + flat.addEventListener("click", action()); + dis.addEventListener("click", action("DIS")); + + window.addEventListener("click", detectLightDismiss, { + capture: true, + passive: true, + }); + + window.addEventListener("scroll", removeAllMenus, { + capture: true, + passive: true, + }); +}; + +const positionMenu = (button, contextmenu) => { + const menu = contextmenu.querySelector(".menu"); + const arrow = contextmenu.querySelector(" .arrow"); + + const buttonRect = button.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + + const top = + (buttonRect.top + buttonRect.bottom) / 2 - + menuRect.height / 2 + + window.scrollY; + + menu.style.top = `${top}px`; + + // Avoid end of screen + if (window.innerWidth < buttonRect.right + menuRect.width + 10) { + arrow.classList.add("right"); + menu.style.left = `${buttonRect.left - menuRect.right - 10}px`; + } else { + arrow.classList.add("left"); + menu.style.left = `${buttonRect.right - menuRect.left + 10}px`; + } +}; + +const contextMenu = (event) => { + const diceButton = event.target.closest(".integrated-dice__container"); + if (!diceButton) { + return; + } + + event.preventDefault(); + + removeAllMenus(); + + const contextmenu = document.createElement("div"); + contextmenu.classList.add("tales-beyond-extension-contextmenu"); + contextmenu.innerHTML = ` + + `; + const img = contextmenu.querySelector("img"); + img.src = svgLogo; + + document.body.appendChild(contextmenu); + positionMenu(diceButton, contextmenu); + setupListeners(diceButton, contextmenu); +}; + +export const injectContextMenu = async () => { + const settings = await getOptions(); + if (settings.contextMenuEnabled) { + window.addEventListener("contextmenu", contextMenu); + } else { + window.removeEventListener("contextmenu", contextMenu); + } +}; diff --git a/src/css/contextmenu.css b/src/css/contextmenu.css new file mode 100644 index 0000000..43942de --- /dev/null +++ b/src/css/contextmenu.css @@ -0,0 +1,69 @@ +.tales-beyond-extension-contextmenu .arrow { + position: absolute; + width: 0; + height: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + top: calc(50% - 10px); +} + +.tales-beyond-extension-contextmenu .arrow.left { + border-right: 10px solid #27272c; + left: -10px; +} + +.tales-beyond-extension-contextmenu .arrow.right { + border-left: 10px solid #27272c; + right: -10px; +} + +.tales-beyond-extension-contextmenu .menu { + position: absolute; + z-index: 2000; + background: #27272c; + padding: 6px 8px; + color: #c5c5ce; + border-radius: .5em; + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, 0.3); + text-align: center; + font-size: 12px; + min-width: max-content; + /* Required because of Popover API */ + inset: 0px auto auto 0px; + margin: 0; + border: none; +} + +.tales-beyond-extension-contextmenu .item { + padding: 6px 8px; + margin: 2px; + line-height: 18px; +} + +.tales-beyond-extension-contextmenu .item:hover { + background-color: #ab79d6; + color: #351d4a; + cursor: pointer; +} + +.tales-beyond-extension-contextmenu .item.advantage:not(:hover) { + text-decoration: green wavy underline; +} + +.tales-beyond-extension-contextmenu .item.disadvantage:not(:hover) { + text-decoration: red wavy underline; +} + +.tales-beyond-extension-contextmenu h2 { + color: #ab79d6; + margin: 0rem 0 0 0; + display: flex; + gap: .5rem; + line-height: 1.2; + align-items: center; + font-size: 1rem; +} + +.tales-beyond-extension-contextmenu h2 img { + width: 1.5rem; +} diff --git a/src/options.html b/src/options.html index 7db67f7..9d3402d 100644 --- a/src/options.html +++ b/src/options.html @@ -16,6 +16,11 @@

Tales Beyond

+
+

General

+
+
+

Modifier key behavior

diff --git a/src/options.js b/src/options.js index e674c59..2b69c88 100644 --- a/src/options.js +++ b/src/options.js @@ -1,6 +1,15 @@ import { mods } from "~/mods"; import { getOptions, saveOption } from "~/utils/storage"; +const general = [ + { + id: "contextMenuEnabled", + header: "Right-click menu", + description: + "Open a menu when right-clicking dice buttons to roll with Advantage or Disadvantage.", + }, +]; + const keys = [ { id: "modifierKeyShift", @@ -72,6 +81,30 @@ const restoreOptions = async () => { modList.append(option); } + + const generalList = document.querySelector("#general-list"); + for (const opt of general) { + // Re-use mod template + const option = modTemplate.content.cloneNode(true); + option.querySelector("label").setAttribute("for", opt.id); + + const input = option.querySelector("input"); + input.addEventListener("change", (event) => + saveOption(opt.id, event.target.checked), + ); + input.setAttribute("id", opt.id); + if (settings[opt.id]) { + input.setAttribute("checked", ""); + } + + const description = option.querySelector(".description"); + description.firstElementChild.textContent = opt.header; + + const text = document.createTextNode(opt.description); + description.appendChild(text); + + generalList.append(option); + } }; document.addEventListener("readystatechange", (event) => { diff --git a/src/utils/storage.js b/src/utils/storage.js index 0d6e389..fa4d6c9 100644 --- a/src/utils/storage.js +++ b/src/utils/storage.js @@ -1,6 +1,7 @@ const VERSION = 1; const defaultOptions = { version: VERSION, + contextMenuEnabled: true, modifierKeyAlt: "adv-dis", modifierKeyCtrl: "adv-dis", modifierKeyShift: "adv-dis", diff --git a/src/utils/talespire.js b/src/utils/talespire.js index e54b502..aee0108 100644 --- a/src/utils/talespire.js +++ b/src/utils/talespire.js @@ -31,6 +31,30 @@ const checkModifierKeys = (event, name) => { return modifierAction(action, name); }; +export const triggerTalespire = (name, dice, extraDice) => { + dice = dice.replace(/d100/g, "d100+d10"); + + let uri; + if (typeof name === "string") { + uri = `talespire://dice/${encodeURIComponent(name)}:${dice}${extraDice ? `/${dice}` : ""}`; + } else { + uri = `talespire://dice/${dice}${extraDice ? `/${dice}` : ""}`; + } + + if (TB_DRY_RUN_TALESPIRE_LINKS === "true") { + // biome-ignore lint/suspicious/noConsoleLog: Used during dev only + console.log("TaleSpire Link", { name, dice, extraDice, uri }); + } else if (typeof TS !== "undefined" && TS.dice) { + const rollDescriptors = [{ name: name ?? "", roll: dice }]; + if (extraDice) { + rollDescriptors.push({ name: "", roll: dice }); + } + TS.dice.putDiceInTray(rollDescriptors); + } else { + window.open(uri, "_self"); + } +}; + export const talespireLink = (elem, label, dice, diceLabel) => { label = label?.trim(); @@ -39,33 +63,13 @@ export const talespireLink = (elem, label, dice, diceLabel) => { link.classList.add("tales-beyond-extension"); link.dataset.tsLabel = label; link.dataset.tsDice = dice; - link.onclick = (event) => { + link.addEventListener("click", (event) => { event.stopPropagation(); - dice = dice.replace(/d100/g, "d100+d10"); - const { name, extraDice } = checkModifierKeys(event, label); - let uri; - if (typeof name === "string") { - uri = `talespire://dice/${encodeURIComponent(name)}:${dice}${extraDice ? `/${dice}` : ""}`; - } else { - uri = `talespire://dice/${dice}${extraDice ? `/${dice}` : ""}`; - } - - if (TB_DRY_RUN_TALESPIRE_LINKS === "true") { - // biome-ignore lint/suspicious/noConsoleLog: Used during dev only - console.log("TaleSpire Link", { name, dice, extraDice, uri }); - } else if (typeof TS !== "undefined" && TS.dice) { - const rollDescriptors = [{ name: label ?? "", roll: dice }]; - if (extraDice) { - rollDescriptors.push({ name: "", roll: extraDice }); - } - TS.dice.putDiceInTray(rollDescriptors); - } else { - window.open(uri, "_self"); - } - }; + triggerTalespire(name, dice, extraDice); + }); if (diceLabel) { link.innerText = diceLabel;