/* * Vencord, a Discord client mod * Copyright (c) 2024 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import "./styles.css"; import { definePluginSettings } from "@api/Settings"; import { Button } from "@components/Button"; import { Devs, IS_MAC } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import { copyWithToast } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import { findComponentByCodeLazy } from "@webpack"; import { useEffect, useState } from "@webpack/common"; const KeybindShortcut = findComponentByCodeLazy(".combo,", ".key,"); const cl = classNameFactory("vc-element-highlight-"); const DEFAULT_KEYBIND = ["mod", "shift", "y"]; let active = false; let overlay: HTMLDivElement | null = null; let tooltip: HTMLDivElement | null = null; let lastTarget: Element | null = null; let pendingFrame = 0; let pendingX = 0; let pendingY = 0; let pendingElement: Element | null = null; const colorCache = new WeakMap(); let cachedRules: { selector: string; specificity: number; color: string; }[] = []; function KeybindRecorder() { const [isListening, setIsListening] = useState(false); const currentKeybind = settings.use(["keybind"]).keybind; useEffect(() => { if (!isListening) return; const handleKeyDown = (event: KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); if (["Control", "Shift", "Alt", "Meta"].includes(event.key)) return; const keys: string[] = []; if (event.metaKey) keys.push("meta"); if (event.ctrlKey) keys.push(IS_MAC ? "control" : "mod"); if (event.shiftKey) keys.push("shift"); if (event.altKey) keys.push("alt"); let mainKey = event.key.toLowerCase(); if (mainKey === " ") mainKey = "space"; if (mainKey === "escape") mainKey = "esc"; keys.push(mainKey); settings.store.keybind = keys; setIsListening(false); }; const handleBlur = () => setIsListening(false); document.addEventListener("keydown", handleKeyDown, true); window.addEventListener("blur", handleBlur); return () => { document.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("blur", handleBlur); }; }, [isListening]); return (
); } const settings = definePluginSettings({ keybind: { description: "Toggle Highlighter", type: OptionType.COMPONENT, default: DEFAULT_KEYBIND, component: KeybindRecorder }, showClasses: { description: "Display the element's CSS class names in the tooltip", type: OptionType.BOOLEAN, default: false }, showId: { description: "Display the element's ID attribute in the tooltip", type: OptionType.BOOLEAN, default: false }, showFont: { description: "Display the computed font family and font size", type: OptionType.BOOLEAN, default: false }, showPadding: { description: "Display the element's padding values", type: OptionType.BOOLEAN, default: false }, showMargin: { description: "Display the element's margin values", type: OptionType.BOOLEAN, default: false }, showBorderRadius: { description: "Display the element's border-radius values", type: OptionType.BOOLEAN, default: false }, showPosition: { description: "Display the element's CSS position type and z-index", type: OptionType.BOOLEAN, default: false }, showDisplay: { description: "Display the element's display type along with flex or grid properties", type: OptionType.BOOLEAN, default: false } }); function calcSpecificity(sel: string): number { const ids = (sel.match(/#[\w-]+/g) ?? []).length; const classes = (sel.match(/\.[\w-]+/g) ?? []).length + (sel.match(/\[[\w-]+/g) ?? []).length; const tags = (sel.match(/(^|[\s>+~])[\w-]+/g) ?? []).length; return ids * 10000 + classes * 100 + tags; } function cacheRules() { cachedRules = []; for (const sheet of document.styleSheets) { try { for (const rule of sheet.cssRules) { if (!(rule instanceof CSSStyleRule)) continue; const color = rule.style.getPropertyValue("color"); if (!color) continue; for (const sel of rule.selectorText.split(",")) { const s = sel.trim(); cachedRules.push({ selector: s, specificity: calcSpecificity(s), color }); } } } catch { } } cachedRules.sort((a, b) => a.specificity - b.specificity); } function getColorVarFromElement(el: Element): string | null { if (el instanceof HTMLElement) { const inline = el.style.getPropertyValue("color"); if (inline) return inline; } const styleAttr = el.getAttribute("style"); if (styleAttr) { const match = styleAttr.match(/color\s*:\s*([^;]+)/); if (match) return match[1].trim(); } let result: string | null = null; for (const r of cachedRules) { try { if (el.matches(r.selector)) result = r.color; } catch { } } return result; } function getColorVar(el: Element): string | null { const cached = colorCache.get(el); if (cached !== undefined) return cached; let current: Element | null = el; while (current) { const color = getColorVarFromElement(current); if (color) { colorCache.set(el, color); return color; } current = current.parentElement; } colorCache.set(el, null); return null; } function rgbToHex(rgb: string): string { const match = rgb.match(/\d+/g); if (!match || match.length < 3) return rgb; const [r, g, b] = match.map(Number); return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } function formatBoxValue(value: string): string | null { if (value === "0px" || value === "0px 0px 0px 0px") return null; return value; } function buildTooltipContent(el: Element, computed: CSSStyleDeclaration, rect: DOMRect): string { const tag = el.tagName.toLowerCase(); const colorVar = getColorVar(el) ?? computed.color; const hex = rgbToHex(computed.color); let html = `<${tag}> `; html += `${Math.round(rect.width)}x${Math.round(rect.height)}`; html += `
${colorVar}
`; html += `
${hex}
`; const { store } = settings; if (store.showId) { const { id } = el; if (id) html += `
id: #${id}
`; } if (store.showClasses) { const classes = el.className; if (classes && typeof classes === "string") { const truncated = classes.length > 60 ? classes.slice(0, 60) + "…" : classes; html += `
class: ${truncated}
`; } } if (store.showFont) { const font = computed.fontFamily.split(",")[0].replace(/["']/g, ""); const size = computed.fontSize; html += `
font: ${font} ${size}
`; } if (store.showPadding) { const padding = formatBoxValue(computed.padding); if (padding) html += `
padding: ${padding}
`; } if (store.showMargin) { const margin = formatBoxValue(computed.margin); if (margin) html += `
margin: ${margin}
`; } if (store.showBorderRadius) { const radius = formatBoxValue(computed.borderRadius); if (radius) html += `
radius: ${radius}
`; } if (store.showPosition) { const pos = computed.position; if (pos !== "static") { const zIndex = computed.zIndex !== "auto" ? ` z:${computed.zIndex}` : ""; html += `
position: ${pos}${zIndex}
`; } } if (store.showDisplay) { const { display } = computed; let extra = ""; if (display === "flex" || display === "inline-flex") { extra = ` ${computed.flexDirection} gap:${computed.gap}`; } else if (display === "grid" || display === "inline-grid") { extra = ` gap:${computed.gap}`; } html += `
display: ${display}${extra}
`; } return html; } function updateFrame() { pendingFrame = 0; const el = pendingElement; if (!el || !overlay || !tooltip) return; const rect = el.getBoundingClientRect(); overlay.style.transform = `translate3d(${rect.left}px,${rect.top}px,0)`; overlay.style.width = `${rect.width}px`; overlay.style.height = `${rect.height}px`; if (el !== lastTarget) { lastTarget = el; const computed = getComputedStyle(el); tooltip.innerHTML = buildTooltipContent(el, computed, rect); } positionTooltip(pendingX, pendingY); } function positionTooltip(x: number, y: number) { if (!tooltip) return; const tw = tooltip.offsetWidth; const th = tooltip.offsetHeight; const margin = 15; const edge = 10; const vw = window.innerWidth; const vh = window.innerHeight; let left = x + margin; let top = y + margin; if (left + tw + edge > vw) { left = x - margin - tw; if (left < edge) left = Math.max(edge, vw - tw - edge); } if (top + th + edge > vh) { top = y - margin - th; if (top < edge) top = Math.max(edge, vh - th - edge); } tooltip.style.transform = `translate3d(${left}px,${top}px,0)`; } function scheduleFrame(el: Element, x: number, y: number) { pendingElement = el; pendingX = x; pendingY = y; if (!pendingFrame) { pendingFrame = requestAnimationFrame(updateFrame); } } function onMouseMove(e: MouseEvent) { const el = document.elementFromPoint(e.clientX, e.clientY); if (!el || el === overlay || el === tooltip || tooltip?.contains(el)) return; scheduleFrame(el, e.clientX, e.clientY); } function onClick(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); const el = document.elementFromPoint(e.clientX, e.clientY); if (!el || el === overlay || el === tooltip) return; const colorVar = getColorVar(el); if (colorVar) copyWithToast(colorVar, "Copied color to clipboard!"); } function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") disable(); } function enable() { active = true; cacheRules(); overlay = document.createElement("div"); overlay.className = cl("overlay"); document.body.appendChild(overlay); tooltip = document.createElement("div"); tooltip.className = cl("tooltip"); document.body.appendChild(tooltip); document.addEventListener("mousemove", onMouseMove, { capture: true, passive: true }); document.addEventListener("click", onClick, true); document.addEventListener("keydown", onKeyDown); document.body.classList.add(cl("active")); } function disable() { active = false; lastTarget = null; pendingElement = null; if (pendingFrame) { cancelAnimationFrame(pendingFrame); pendingFrame = 0; } overlay?.remove(); overlay = null; tooltip?.remove(); tooltip = null; document.removeEventListener("mousemove", onMouseMove, true); document.removeEventListener("click", onClick, true); document.removeEventListener("keydown", onKeyDown); document.body.classList.remove(cl("active")); } function getConfiguredKeybind(): string[] { const raw = settings.store.keybind; if (Array.isArray(raw) && raw.length > 0) return raw; return DEFAULT_KEYBIND; } function matchesKeybind(e: KeyboardEvent): boolean { const keybind = getConfiguredKeybind(); const pressed = e.key.toLowerCase(); const code = e.code.toLowerCase().replace("key", "").replace("digit", ""); let hasNonModifier = false; for (const key of keybind) { switch (key) { case "mod": if (!(IS_MAC ? e.metaKey : e.ctrlKey)) return false; break; case "control": case "ctrl": if (!e.ctrlKey) return false; break; case "meta": case "cmd": if (!e.metaKey) return false; break; case "shift": if (!e.shiftKey) return false; break; case "alt": if (!e.altKey) return false; break; default: hasNonModifier = true; if (pressed !== key && code !== key) return false; } } return hasNonModifier; } function onToggle(e: KeyboardEvent) { if (matchesKeybind(e)) { e.preventDefault(); active ? disable() : enable(); } } export default definePlugin({ name: "ElementHighlighter", description: "Highlight and inspect elements easily.", tags: ["Developers"], authors: [Devs.prism], settings, start() { document.addEventListener("keydown", onToggle); }, stop() { document.removeEventListener("keydown", onToggle); if (active) disable(); } });