diff --git a/src/plugins/imageZoom/index.tsx b/src/plugins/imageZoom/index.tsx index 56e1d4a2..2579b2fc 100644 --- a/src/plugins/imageZoom/index.tsx +++ b/src/plugins/imageZoom/index.tsx @@ -20,7 +20,7 @@ import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { definePluginSettings } from "@api/Settings"; import { makeRange } from "@components/PluginSettings/components"; import { debounce } from "@shared/debounce"; -import { Devs } from "@utils/constants"; +import { Devs, EquicordDevs } from "@utils/constants"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType } from "@utils/types"; import { createRoot, Menu } from "@webpack/common"; @@ -31,6 +31,19 @@ import { Magnifier, MagnifierProps } from "./components/Magnifier"; import { ELEMENT_ID } from "./constants"; import managedStyle from "./styles.css?managed"; + +interface ImageMetadata { + filename: string; + dimensions: string; + size?: string; + fetching?: boolean; +} + +const imageMetadataCache = new Map(); + +let lastClickTime = 0; +const DOUBLE_CLICK_THRESHOLD = 300; + export const settings = definePluginSettings({ saveZoomValues: { type: OptionType.BOOLEAN, @@ -78,6 +91,12 @@ export const settings = definePluginSettings({ default: 0.5, stickToMarkers: false, }, + + showMetadata: { + type: OptionType.BOOLEAN, + description: "Show image metadata when double clicking on selected image", + default: true, + } }); @@ -87,7 +106,7 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => // emojis in user statuses if (props.target?.classList?.contains("emoji")) return; - const { square, nearestNeighbour } = settings.use(["square", "nearestNeighbour"]); + const { square, nearestNeighbour, showMetadata } = settings.use(["square", "nearestNeighbour", "showMetadata"]); children.push( @@ -150,14 +169,138 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => /> )} /> + + { + settings.store.showMetadata = !showMetadata; + }} + /> + { + const target = props.target as HTMLImageElement; + if (target && target.src) { + toggleMetadata(target); + } + }} + /> ); }; +function toggleMetadata(imgElement: HTMLImageElement) { + if (!imgElement || !imgElement.src) return; + const parent = imgElement.parentElement; + if (!parent) return; + + const metadataContainer = parent.querySelector(".vc-image-metadata"); + if (metadataContainer) { + metadataContainer.remove(); + return; + } + + createMetadataDisplay(imgElement); +} + +function createMetadataDisplay(imgElement: HTMLImageElement) { + if (!imgElement || !imgElement.src) return; + + const { src } = imgElement; + const parent = imgElement.parentElement; + if (!parent) return; + + const wrapper = document.createElement("div"); + wrapper.className = "vc-image-wrapper"; + parent.insertBefore(wrapper, imgElement); + wrapper.appendChild(imgElement); + + let metadata = imageMetadataCache.get(src); + + if (!metadata) { + metadata = { + filename: getFilenameFromURL(src), + dimensions: `${imgElement.naturalWidth || imgElement.width} × ${imgElement.naturalHeight || imgElement.height} px`, + fetching: true + }; + + imageMetadataCache.set(src, metadata); + fetchFileSize(src).then(size => { + if (size !== undefined) { + const cachedMetadata = imageMetadataCache.get(src); + if (cachedMetadata) { + cachedMetadata.size = formatFileSize(size); + cachedMetadata.fetching = false; + imageMetadataCache.set(src, cachedMetadata); + + const container = parent.querySelector(".vc-image-metadata"); + if (container) { + const sizeElement = container.querySelector(".vc-metadata-row:last-child span:last-child"); + if (sizeElement) { + sizeElement.textContent = formatFileSize(size); + } + } + } + } + }); + } + + const container = document.createElement("div"); + container.className = "vc-image-metadata"; + container.innerHTML = ` +
+ + ${metadata.filename} +
+
+ + ${metadata.dimensions} +
+
+ + ${metadata.size || "Loading..."} +
+ `; + + wrapper.appendChild(container); + + return container; +} + +function getFilenameFromURL(url: string): string { + try { + const cleanUrl = url.split("?")[0]; + const parts = cleanUrl.split("/"); + return decodeURIComponent(parts[parts.length - 1]); + } catch { + return "Unknown"; + } +} + +async function fetchFileSize(url: string): Promise { + try { + const response = await fetch(url, { method: "HEAD" }); + return parseInt(response.headers.get("content-length") || "0"); + } catch { + return undefined; + } +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + export default definePlugin({ name: "ImageZoom", - description: "Lets you zoom in to images and gifs. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius / size", - authors: [Devs.Aria], + description: "Lets you zoom in to images and gifs as well as displays image metadata. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius.", + authors: [Devs.Aria, EquicordDevs.Campfire], tags: ["ImageUtilities"], managedStyle, @@ -171,7 +314,6 @@ export default definePlugin({ replace: `id:"${ELEMENT_ID}",$&` }, { - // This patch needs to be above the next one as it uses the zoomed class as an anchor match: /\.zoomed]:.+?,(?=children:)/, replace: "$&onClick:()=>{}," }, @@ -181,7 +323,6 @@ export default definePlugin({ }, ] }, - // Make media viewer options not hide when zoomed in with the default Discord feature { find: '="FOCUS_SENSITIVE",', replacement: { @@ -189,7 +330,6 @@ export default definePlugin({ replace: "false" } }, - { find: ".handleImageLoad)", replacement: [ @@ -197,17 +337,14 @@ export default definePlugin({ match: /placeholderVersion:\i,(?=.{0,50}children:)/, replace: "...$self.makeProps(this),$&" }, - { match: /componentDidMount\(\){/, replace: "$&$self.renderMagnifier(this);", }, - { match: /componentWillUnmount\(\){/, replace: "$&$self.unMountMagnifier();" }, - { match: /componentDidUpdate\(\i\){/, replace: "$&$self.updateMagnifier(this);" @@ -221,22 +358,35 @@ export default definePlugin({ "image-context": imageContextMenuPatch }, - // to stop from rendering twice /shrug currentMagnifierElement: null as React.FunctionComponentElement | null, element: null as HTMLDivElement | null, - Magnifier, root: null as Root | null, + makeProps(instance) { return { onMouseOver: () => this.onMouseOver(instance), onMouseOut: () => this.onMouseOut(instance), onMouseDown: (e: React.MouseEvent) => this.onMouseDown(e, instance), onMouseUp: () => this.onMouseUp(instance), + onClick: (e: React.MouseEvent) => this.handleImageClick(e, instance), id: instance.props.id, }; }, + handleImageClick(e: React.MouseEvent | MouseEvent, instance: any) { + if (!settings.store.showMetadata) return; + + const target = e.target as HTMLImageElement; + if (target && target.tagName === "IMG" && target.src) { + const currentTime = new Date().getTime(); + if (currentTime - lastClickTime < DOUBLE_CLICK_THRESHOLD) { + toggleMetadata(target); + } + lastClickTime = currentTime; + } + }, + renderMagnifier(instance) { try { if (instance.props.id === ELEMENT_ID) { @@ -280,11 +430,41 @@ export default definePlugin({ this.element = document.createElement("div"); this.element.classList.add("MagnifierContainer"); document.body.appendChild(this.element); + + const style = document.createElement("style"); + style.id = "image-metadata-styles"; + style.textContent = ` + .vc-image-metadata { + padding: 8px; + margin: 6px 0; + background-color: var(--background-secondary); + border-radius: 4px; + font-size: 14px; + color: var(--text-normal); + display: flex; + flex-direction: column; + gap: 4px; + } + + .vc-metadata-row { + display: flex; + justify-content: space-between; + } + + .vc-metadata-label { + font-weight: 600; + margin-right: 8px; + } + `; + document.head.appendChild(style); }, stop() { // so componenetWillUnMount gets called if Magnifier component is still alive this.root && this.root.unmount(); this.element?.remove(); + + document.getElementById("image-metadata-styles")?.remove(); + document.querySelectorAll(".vc-image-metadata").forEach(el => el.remove()); } }); diff --git a/src/plugins/imageZoom/styles.css b/src/plugins/imageZoom/styles.css index 82790aef..90144574 100644 --- a/src/plugins/imageZoom/styles.css +++ b/src/plugins/imageZoom/styles.css @@ -21,3 +21,20 @@ /* https://googlechrome.github.io/samples/image-rendering-pixelated/index.html */ } + +.vc-image-metadata { + position: absolute; + top: 0; + right: 0; + background: rgb(0 0 0 / 60%); + color: white; + padding: 4px 8px; + font-size: 12px; + z-index: 10; + pointer-events: none; +} + +.vc-image-wrapper { + position: relative; + display: inline-block; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b798c88e..4eb44ad0 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1061,6 +1061,10 @@ export const EquicordDevs = Object.freeze({ Reycko: { name: "Reycko", id: 1123725368004726794n, + }, + Campfire: { + name: "Campfire", + id: 376414446840578081n, } } satisfies Record);