From ef74368a2f6084ae1a0d40e6b63384179791c7ea Mon Sep 17 00:00:00 2001 From: Creation's Date: Tue, 8 Oct 2024 12:01:51 -0400 Subject: [PATCH] feat(plugin): ImagePreview (#47) * add imagePreview plugin, add myself to EquicordDevs constant, and update the readme * Update README.md update additional plugin count * Fix * Fix Dir Name * Fix My Mistake --------- Co-authored-by: thororen1234 Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- README.md | 3 +- package.json | 2 +- src/equicordplugins/imagePreview/index.ts | 397 ++++++++++++++++++++ src/equicordplugins/imagePreview/styles.css | 63 ++++ src/equicordplugins/loginWithQR/index.tsx | 12 +- src/utils/constants.ts | 4 + 6 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 src/equicordplugins/imagePreview/index.ts create mode 100644 src/equicordplugins/imagePreview/styles.css diff --git a/README.md b/README.md index ff6cf50a..5577fad1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch ### Extra included plugins
-125 additional plugins +126 additional plugins - AllCallTimers by MaxHerbold & D3SOX - AltKrispSwitch by newwares @@ -67,6 +67,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch - InRole by nin0dev - IrcColors by Grzesiek11 - IRememberYou by zoodogood +- ImagePreview by Creation's - Jumpscare by Surgedevs - JumpToStart by Samwich - KeyboardSounds by HypedDomi diff --git a/package.json b/package.json index 5ee0b6c0..77396d5b 100644 --- a/package.json +++ b/package.json @@ -117,4 +117,4 @@ "node": ">=18", "pnpm": ">=9" } -} +} \ No newline at end of file diff --git a/src/equicordplugins/imagePreview/index.ts b/src/equicordplugins/imagePreview/index.ts new file mode 100644 index 00000000..f78d9128 --- /dev/null +++ b/src/equicordplugins/imagePreview/index.ts @@ -0,0 +1,397 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { definePluginSettings } from "@api/Settings"; + +const eventListeners: { element: HTMLElement, handler: (e: any) => void; }[] = []; +let lastHoveredElement: HTMLElement | null = null; + +const mimeTypes = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", +}; + +function getMimeType(extension: string | undefined): [boolean, string] { + if (!extension) return [false, ""]; + + const lowerExt = extension.trim().toLowerCase(); + return [!!mimeTypes[lowerExt], mimeTypes[lowerExt] || ""]; +} + +function addHoverEffect(element: HTMLElement, type: string) { + let hoverElementActual; + + if (settings.store.hoverOutline) { + if (type === "messageImages") { + hoverElementActual = element.closest('[id^="message-accessories-"]')?.querySelector('div')?.querySelector('div') || element; + + if (!(hoverElementActual instanceof HTMLDivElement)) { + hoverElementActual = element; + } + } else { + hoverElementActual = element.querySelector("img") || element; + } + + hoverElementActual.style.outline = `${settings.store.hoverOutlineSize} dotted ${settings.store.hoverOutlineColor}`; + } + + const url = element.getAttribute("data-safe-src") || element.getAttribute("src") || element.getAttribute("href") || element.textContent; + + if (!url) { + hoverElementActual.style.outline = ""; + return; + } + + const strippedUrl = stripDiscordParams(url); + const fileName: string = strippedUrl.split("/").pop()?.split(/[?#&]/)[0] || "unknown"; + const [allowed, mimeType] = getMimeType(fileName.split(".").pop()); + + if (!allowed) { + hoverElementActual.style.outline = ""; + return; + } + + const isImage = allowed && mimeType.startsWith("image"); + const isVideo = allowed && mimeType.startsWith("video"); + + const previewDiv = document.createElement("div"); + previewDiv.classList.add("preview-div"); + + let mediaElement; + if (isImage) { + mediaElement = document.createElement("img"); + mediaElement.src = strippedUrl; + mediaElement.alt = fileName; + } else if (isVideo) { + mediaElement = document.createElement("video"); + mediaElement.src = strippedUrl; + mediaElement.autoplay = true; + mediaElement.muted = true; + mediaElement.loop = true; + mediaElement.controls = false; + } else { + return; + } + + const previewHeader = document.createElement("div"); + previewHeader.classList.add("preview-header"); + + const mimeSpan = document.createElement("span"); + mimeSpan.textContent = `MIME: ${mimeType}`; + previewHeader.appendChild(mimeSpan); + + const fileNameSpan = document.createElement("span"); + fileNameSpan.classList.add("file-name"); + fileNameSpan.textContent = fileName; + previewHeader.appendChild(fileNameSpan); + + const dimensionsDiv = document.createElement("div"); + dimensionsDiv.classList.add("dimensions-div"); + + const dimensionsDisplaying = document.createElement("span"); + dimensionsDisplaying.classList.add("dimensions-displaying"); + const dimensionsOriginal = document.createElement("span"); + dimensionsOriginal.classList.add("dimensions-original"); + + mediaElement.onload = mediaElement.onloadstart = () => { + if (isImage) { + dimensionsDisplaying.textContent = `Displaying: ${mediaElement.width}x${mediaElement.height}`; + dimensionsOriginal.textContent = `Original: ${mediaElement.naturalWidth}x${mediaElement.naturalHeight}`; + } else if (isVideo) { + dimensionsDisplaying.textContent = `Displaying: ${mediaElement.videoWidth}x${mediaElement.videoHeight}`; + dimensionsOriginal.textContent = `Original: ${mediaElement.videoWidth}x${mediaElement.videoHeight}`; + } + + if (mediaElement.width < 200) { + previewHeader.style.flexDirection = "column"; + previewHeader.style.alignItems = "center"; + previewHeader.style.justifyContent = "center"; + dimensionsDiv.style.textAlign = "center"; + dimensionsDiv.style.alignItems = "center"; + previewHeader.style.gap = "5px"; + previewHeader.insertBefore(fileNameSpan, previewHeader.firstChild); + } + }; + + dimensionsDiv.appendChild(dimensionsDisplaying); + dimensionsDiv.appendChild(dimensionsOriginal); + previewHeader.appendChild(dimensionsDiv); + + previewDiv.appendChild(previewHeader); + previewDiv.appendChild(mediaElement); + + document.body.appendChild(previewDiv); + + const hoverDelay = settings.store.hoverDelay * 1000; + let timeout; + + const showPreview = () => { + timeout = setTimeout(() => { + previewDiv.style.display = "block"; + hoverElementActual.style.outline = `${settings.store.hoverOutlineSize} dotted ${settings.store.hoverOutlineColor}`; + positionPreviewDiv(previewDiv, null); + }, hoverDelay); + }; + + const movePreviewListener: (e: MouseEvent) => void = (e) => { + positionPreviewDiv(previewDiv, e); + }; + + const removePreview = () => { + clearTimeout(timeout); + previewDiv.remove(); + document.removeEventListener("mousemove", movePreviewListener); + if (hoverElementActual) { + hoverElementActual.style.outline = ""; + } + }; + + element.addEventListener("mouseenter", showPreview); + element.addEventListener("mouseleave", removePreview); + document.addEventListener("mousemove", movePreviewListener); + + eventListeners.push({ element, handler: showPreview }); + eventListeners.push({ element, handler: removePreview }); + eventListeners.push({ element: previewDiv, handler: movePreviewListener }); + + function positionPreviewDiv(previewDiv: HTMLElement, e: MouseEvent | null) { + const previewWidth = previewDiv.offsetWidth; + const previewHeight = previewDiv.offsetHeight; + const pageWidth = window.innerWidth; + const pageHeight = window.innerHeight; + + const mouseX = e ? e.pageX : window.innerWidth / 2; + const mouseY = e ? e.pageY : window.innerHeight / 2; + + let left = mouseX + 10; + let top = mouseY + 10; + + if (left + previewWidth > pageWidth) { + left = pageWidth - previewWidth - 10; + } + if (top + previewHeight > pageHeight) { + top = pageHeight - previewHeight - 10; + } + + previewDiv.style.left = `${left}px`; + previewDiv.style.top = `${top}px`; + + const maxImageWidth = pageWidth - 20; + const maxImageHeight = pageHeight - 20; + + if (isImage) { + if (mediaElement.naturalWidth > maxImageWidth || mediaElement.naturalHeight > maxImageHeight) { + const aspectRatio = mediaElement.naturalWidth / mediaElement.naturalHeight; + + if (mediaElement.naturalWidth > maxImageWidth) { + mediaElement.width = maxImageWidth; + mediaElement.height = maxImageWidth / aspectRatio; + } + if (mediaElement.height > maxImageHeight) { + mediaElement.height = maxImageHeight; + mediaElement.width = maxImageHeight * aspectRatio; + } + } else { + mediaElement.width = mediaElement.naturalWidth; + mediaElement.height = mediaElement.naturalHeight; + } + } + + dimensionsDisplaying.textContent = isImage ? `Displaying: ${mediaElement.width}x${mediaElement.height}` : `Displaying: ${mediaElement.videoWidth}x${mediaElement.videoHeight}`; + dimensionsOriginal.textContent = isImage ? `Original: ${mediaElement.naturalWidth}x${mediaElement.naturalHeight}` : `Original: ${mediaElement.videoWidth}x${mediaElement.videoHeight}`; + } +} + +function handleHover(elements: NodeListOf | HTMLElement[], type: string) { + elements.forEach((el) => { + if (!el.dataset.hoverListenerAdded) { + const handler = () => addHoverEffect(el, type); + el.addEventListener("mouseover", handler); + el.dataset.hoverListenerAdded = "true"; + eventListeners.push({ element: el, handler }); + } + }); +} + +function isLinkAnImage(url: string) { + const extension = url.split(".").pop(); + const [isImage,] = getMimeType(extension); + return isImage; +} + +function stripDiscordParams(url: string) { + let newUrl = url.replace(/([?&])(width|size|height|h|w)=[^&]+/g, ""); + + newUrl = newUrl.replace(/([?&])quality=[^&]*/g, "$1quality=lossless"); + + newUrl = newUrl.replace(/([?&])+$/, "") + .replace(/\?&/, "?") + .replace(/\?$/, "") + .replace(/&{2,}/g, "&"); + + if (newUrl.includes("quality=lossless") && !newUrl.includes("?")) { + newUrl = newUrl.replace(/&quality=lossless/, "?quality=lossless"); + } + + return newUrl; +} + +const settings = definePluginSettings({ + messageImages: { + type: OptionType.BOOLEAN, + description: "Enable Message Images Hover Detection", + default: true, + }, + messageAvatars: { + type: OptionType.BOOLEAN, + description: "Enable Message Avatars Hover Detection", + default: true, + }, + messageLinks: { + type: OptionType.BOOLEAN, + description: "Enable Message Links Hover Detection", + default: true, + }, + messageStickers: { + type: OptionType.BOOLEAN, + description: "Enable Message Stickers Hover Detection", + default: true, + }, + hoverOutline: { + type: OptionType.BOOLEAN, + description: "Enable Hover Outline on Elements", + default: true, + }, + hoverOutlineColor: { + type: OptionType.STRING, + description: "Hover Outline Color", + default: "red", + }, + hoverOutlineSize: { + type: OptionType.STRING, + description: "Hover Outline Size", + default: "1px", + }, + hoverDelay: { + type: OptionType.SLIDER, + description: "Display Hover Delay (seconds)", + markers: [0, 1, 2, 3, 4, 5], + default: 1, + }, +}); + +export default definePlugin({ + name: "Image Preview", + description: "Hover on images, avatars, links, guild icons, and stickers to show a full preview.", + authors: [EquicordDevs.creations], + settings: settings, + + start() { + function initialScan() { + const appContainer = document.querySelector('[class*="app-"]'); + if (appContainer) { + if (settings.store.messageImages) { + handleHover(appContainer.querySelectorAll('[data-role="img"]'), "messageImages"); + } + + if (settings.store.messageAvatars) { + handleHover(appContainer.querySelectorAll('img[src*="cdn.discordapp.com/avatars/"], img[src*="cdn.discordapp.com/guilds/"], img[src^="/assets/"][class*="avatar"]'), "messageAvatars"); + } + + if (settings.store.messageLinks) { + appContainer.querySelectorAll("span").forEach((span) => { + const url = span.textContent?.replace(/<[^>]*>?/gm, ""); + if (url && (url.startsWith("http://") || url.startsWith("https://")) && isLinkAnImage(url)) { + handleHover([span], "messageLinks"); + } + }); + } + + if (settings.store.messageStickers) { + handleHover(appContainer.querySelectorAll('img[data-type="sticker"]'), "messageStickers"); + } + } + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "childList") { + mutation.addedNodes.forEach((addedNode) => { + if (addedNode instanceof HTMLElement) { + const element = addedNode as HTMLElement; + + if (lastHoveredElement === element) return; + lastHoveredElement = element; + + if (settings.store.messageImages) { + handleHover(element.querySelectorAll('[data-role="img"]'), "messageImages"); + } + + if (settings.store.messageAvatars) { + handleHover(element.querySelectorAll('img[src*="cdn.discordapp.com/avatars/"], img[src*="cdn.discordapp.com/guilds/"], img[src^="/assets/"][class*="avatar"]'), "messageAvatars"); + } + + if (settings.store.messageLinks) { + element.querySelectorAll("span").forEach((span) => { + const url = span.textContent?.replace(/<[^>]*>?/gm, ""); + if (url && (url.startsWith("http://") || url.startsWith("https://")) && isLinkAnImage(url)) { + handleHover([span], "messageLinks"); + } + }); + } + + if (settings.store.messageStickers) { + handleHover(element.querySelectorAll('img[data-type="sticker"]'), "messageStickers"); + } + } + }); + } + }); + }); + + const appContainer = document.querySelector('[class*="app-"]'); + if (appContainer) { + observer.observe(appContainer, { childList: true, subtree: true }); + } + + initialScan(); + + this.observer = observer; + }, + + stop() { + this.observer.disconnect(); + + eventListeners.forEach(({ element, handler }) => { + element.removeEventListener("mouseover", handler); + element.removeEventListener("mouseenter", handler); + element.removeEventListener("mouseleave", handler); + element.removeEventListener("mousemove", handler); + }); + + eventListeners.length = 0; + + document.querySelectorAll("[data-hover-listener-added]").forEach((el) => { + el.removeAttribute("data-hover-listener-added"); + (el as HTMLElement).style.outline = ""; + }); + + document.querySelectorAll(".preview-div").forEach((preview) => { + preview.remove(); + }); + } +}); diff --git a/src/equicordplugins/imagePreview/styles.css b/src/equicordplugins/imagePreview/styles.css new file mode 100644 index 00000000..e3dee0d2 --- /dev/null +++ b/src/equicordplugins/imagePreview/styles.css @@ -0,0 +1,63 @@ +.preview-div { + position: fixed; + border: 2px solid var(--background-tertiary); + background-color: var(--background-secondary); + padding: 5px; + border-radius: 5px; + pointer-events: none; + z-index: 9999; + display: none; + max-width: 100vw; + max-height: 96vh; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0 0 0 / 40%); +} + +.file-name { + font-size: 15px; + color: var(--text-normal); + text-align: center; + margin-bottom: 5px; + display: block; + word-break: break-all; + max-width: 180px; +} + +.preview-div img { + max-width: 100%; + max-height: 100%; + display: block; + margin: 0 auto; +} + +.preview-div video { + max-width: 100%; + max-height: 100%; + display: block; + margin: 0 auto; +} + +.preview-header { + color: white; + font-size: 12px; + margin-bottom: 5px; + text-align: center; + display: flex; + justify-content: space-between; + align-items: center; + gap: 5px; +} + +.dimensions-div { + display: flex; + flex-direction: column; + gap: 5px; + align-items: flex-end; + font-size: 11px; + color: var(--text-muted); +} + +.dimensions-displaying, +.dimensions-original { + display: block; +} diff --git a/src/equicordplugins/loginWithQR/index.tsx b/src/equicordplugins/loginWithQR/index.tsx index bd2653b6..dbadb292 100644 --- a/src/equicordplugins/loginWithQR/index.tsx +++ b/src/equicordplugins/loginWithQR/index.tsx @@ -5,6 +5,7 @@ */ import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { Button, Forms, i18n, Menu } from "@webpack/common"; import { ReactElement } from "react"; @@ -15,15 +16,8 @@ import openQrModal from "./ui/modals/QrModal"; export default definePlugin({ name: "LoginWithQR", - description: - "Allows you to login to another device by scanning a login QR code, just like on mobile!", - // replace with EquicordDevs.nexpid when merged to Equicord - authors: [ - { - name: "Nexpid", - id: 853550207039832084n, - }, - ], + description: "Allows you to login to another device by scanning a login QR code, just like on mobile!", + authors: [EquicordDevs.nexpid], settings: definePluginSettings({ scanQr: { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 04479b28..ced902cb 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -913,6 +913,10 @@ export const EquicordDevs = Object.freeze({ name: "arHSM", id: 841509053422632990n }, + creations: { + name: "Creation's", + id: 209830981060788225n, + } } satisfies Record); // iife so #__PURE__ works correctly