From df7357b3574bf87eba5caa41e637c73ba2158ee3 Mon Sep 17 00:00:00 2001 From: Syncx <47534062+Syncxv@users.noreply.github.com> Date: Thu, 6 Apr 2023 11:06:11 +1000 Subject: [PATCH] feat(plugin): Image Zoom (#510) Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com> Co-authored-by: Ven --- .../imageZoom/components/Magnifier.tsx | 198 +++++++++++++++ src/plugins/imageZoom/constants.ts | 19 ++ src/plugins/imageZoom/index.tsx | 234 ++++++++++++++++++ src/plugins/imageZoom/styles.css | 31 +++ src/plugins/imageZoom/utils/waitFor.ts | 22 ++ 5 files changed, 504 insertions(+) create mode 100644 src/plugins/imageZoom/components/Magnifier.tsx create mode 100644 src/plugins/imageZoom/constants.ts create mode 100644 src/plugins/imageZoom/index.tsx create mode 100644 src/plugins/imageZoom/styles.css create mode 100644 src/plugins/imageZoom/utils/waitFor.ts diff --git a/src/plugins/imageZoom/components/Magnifier.tsx b/src/plugins/imageZoom/components/Magnifier.tsx new file mode 100644 index 00000000..e61c5602 --- /dev/null +++ b/src/plugins/imageZoom/components/Magnifier.tsx @@ -0,0 +1,198 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { FluxDispatcher, React, useRef, useState } from "@webpack/common"; + +import { ELEMENT_ID } from "../constants"; +import { settings } from "../index"; +import { waitFor } from "../utils/waitFor"; + +interface Vec2 { + x: number, + y: number; +} + +export interface MagnifierProps { + zoom: number; + size: number, + instance: any; +} + +export const Magnifier: React.FC = ({ instance, size: initialSize, zoom: initalZoom }) => { + const [ready, setReady] = useState(false); + + + const [lensPosition, setLensPosition] = useState({ x: 0, y: 0 }); + const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); + const [opacity, setOpacity] = useState(0); + + const isShiftDown = useRef(false); + + const zoom = useRef(initalZoom); + const size = useRef(initialSize); + + const element = useRef(null); + const currentVideoElementRef = useRef(null); + const originalVideoElementRef = useRef(null); + const imageRef = useRef(null); + + // since we accessing document im gonna use useLayoutEffect + React.useLayoutEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") { + isShiftDown.current = true; + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") { + isShiftDown.current = false; + } + }; + const syncVideos = () => { + currentVideoElementRef.current!.currentTime = originalVideoElementRef.current!.currentTime; + }; + + const updateMousePosition = (e: MouseEvent) => { + if (instance.state.mouseOver && instance.state.mouseDown) { + const offset = size.current / 2; + const pos = { x: e.pageX, y: e.pageY }; + const x = -((pos.x - element.current!.getBoundingClientRect().left) * zoom.current - offset); + const y = -((pos.y - element.current!.getBoundingClientRect().top) * zoom.current - offset); + setLensPosition({ x: e.x - offset, y: e.y - offset }); + setImagePosition({ x, y }); + setOpacity(1); + } else { + setOpacity(0); + } + + }; + + const onMouseDown = (e: MouseEvent) => { + if (instance.state.mouseOver && e.button === 0 /* left click */) { + zoom.current = settings.store.zoom; + size.current = settings.store.size; + + // close context menu if open + if (document.getElementById("image-context")) { + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" }); + } + + updateMousePosition(e); + setOpacity(1); + } + }; + + const onMouseUp = () => { + setOpacity(0); + if (settings.store.saveZoomValues) { + settings.store.zoom = zoom.current; + settings.store.size = size.current; + } + }; + + const onWheel = async (e: WheelEvent) => { + if (instance.state.mouseOver && instance.state.mouseDown && !isShiftDown.current) { + const val = zoom.current + ((e.deltaY / 100) * (settings.store.invertScroll ? -1 : 1)) * settings.store.zoomSpeed; + zoom.current = val <= 1 ? 1 : val; + updateMousePosition(e); + } + if (instance.state.mouseOver && instance.state.mouseDown && isShiftDown.current) { + const val = size.current + (e.deltaY * (settings.store.invertScroll ? -1 : 1)) * settings.store.zoomSpeed; + size.current = val <= 50 ? 50 : val; + updateMousePosition(e); + } + }; + + waitFor(() => instance.state.readyState === "READY", () => { + const elem = document.getElementById(ELEMENT_ID) as HTMLDivElement; + element.current = elem; + elem.firstElementChild!.setAttribute("draggable", "false"); + if (instance.props.animated) { + originalVideoElementRef.current = elem!.querySelector("video")!; + originalVideoElementRef.current.addEventListener("timeupdate", syncVideos); + setReady(true); + } else { + setReady(true); + } + }); + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); + document.addEventListener("mousemove", updateMousePosition); + document.addEventListener("mousedown", onMouseDown); + document.addEventListener("mouseup", onMouseUp); + document.addEventListener("wheel", onWheel); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + document.removeEventListener("mousemove", updateMousePosition); + document.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("mouseup", onMouseUp); + document.removeEventListener("wheel", onWheel); + + if (settings.store.saveZoomValues) { + settings.store.zoom = zoom.current; + settings.store.size = size.current; + } + }; + }, []); + + if (!ready) return null; + + const box = element.current!.getBoundingClientRect(); + + return ( +
+ {instance.props.animated ? + ( +
+ ); +}; diff --git a/src/plugins/imageZoom/constants.ts b/src/plugins/imageZoom/constants.ts new file mode 100644 index 00000000..cfde60cf --- /dev/null +++ b/src/plugins/imageZoom/constants.ts @@ -0,0 +1,19 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +export const ELEMENT_ID = "magnify-modal"; diff --git a/src/plugins/imageZoom/index.tsx b/src/plugins/imageZoom/index.tsx new file mode 100644 index 00000000..7a1887b9 --- /dev/null +++ b/src/plugins/imageZoom/index.tsx @@ -0,0 +1,234 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import "./styles.css"; + +import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/settings"; +import { makeRange } from "@components/PluginSettings/components"; +import { Devs } from "@utils/constants"; +import { debounce } from "@utils/debounce"; +import definePlugin, { OptionType } from "@utils/types"; +import { Menu, React, ReactDOM } from "@webpack/common"; +import type { Root } from "react-dom/client"; + +import { Magnifier, MagnifierProps } from "./components/Magnifier"; +import { ELEMENT_ID } from "./constants"; + +export const settings = definePluginSettings({ + saveZoomValues: { + type: OptionType.BOOLEAN, + description: "Whether to save zoom and lens size values", + default: true, + }, + + preventCarouselFromClosingOnClick: { + type: OptionType.BOOLEAN, + // Thanks chat gpt + description: "Allow the image modal in the image slideshow thing / carousel to remain open when clicking on the image", + default: true, + }, + + invertScroll: { + type: OptionType.BOOLEAN, + description: "Invert scroll", + default: true, + }, + + zoom: { + description: "Zoom of the lens", + type: OptionType.SLIDER, + markers: makeRange(1, 50, 4), + default: 2, + stickToMarkers: false, + }, + size: { + description: "Radius / Size of the lens", + type: OptionType.SLIDER, + markers: makeRange(50, 1000, 50), + default: 100, + stickToMarkers: false, + }, + + zoomSpeed: { + description: "How fast the zoom / lens size changes", + type: OptionType.SLIDER, + markers: makeRange(0.1, 5, 0.2), + default: 0.5, + stickToMarkers: false, + }, +}); + + +const imageContextMenuPatch: NavContextMenuPatchCallback = (children, _) => { + if (!children.some(child => child?.props?.id === "image-zoom")) { + children.push( + + {/* thanks SpotifyControls */} + ( + settings.store.zoom = value, 100)} + /> + )} + /> + ( + settings.store.size = value, 100)} + /> + )} + /> + ( + settings.store.zoomSpeed = value, 100)} + renderValue={(value: number) => `${value.toFixed(3)}x`} + /> + )} + /> + + ); + } +}; + +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], + patches: [ + { + find: '"renderLinkComponent","maxWidth"', + replacement: { + match: /(return\(.{1,100}\(\)\.wrapper.{1,100})(src)/, + replace: `$1id: '${ELEMENT_ID}',$2` + } + }, + + { + find: "handleImageLoad=", + replacement: [ + { + match: /(render=function\(\){.{1,500}limitResponsiveWidth.{1,600})onMouseEnter:/, + replace: "$1...$self.makeProps(this),onMouseEnter:" + }, + + { + match: /componentDidMount=function\(\){/, + replace: "$&$self.renderMagnifier(this);", + }, + + { + match: /componentWillUnmount=function\(\){/, + replace: "$&$self.unMountMagnifier();" + } + ] + }, + + { + find: ".carouselModal,", + replacement: { + match: /onClick:(\i),/, + replace: "onClick:$self.settings.store.preventCarouselFromClosingOnClick ? () => {} : $1," + } + } + ], + + settings, + + // 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), + id: instance.props.id, + }; + }, + + renderMagnifier(instance) { + if (instance.props.id === ELEMENT_ID) { + if (!this.currentMagnifierElement) { + this.currentMagnifierElement = ; + this.root = ReactDOM.createRoot(this.element!); + this.root.render(this.currentMagnifierElement); + } + } + }, + + unMountMagnifier() { + this.root?.unmount(); + this.currentMagnifierElement = null; + this.root = null; + }, + + onMouseOver(instance) { + instance.setState((state: any) => ({ ...state, mouseOver: true })); + }, + onMouseOut(instance) { + instance.setState((state: any) => ({ ...state, mouseOver: false })); + }, + onMouseDown(e: React.MouseEvent, instance) { + if (e.button === 0 /* left */) + instance.setState((state: any) => ({ ...state, mouseDown: true })); + }, + onMouseUp(instance) { + instance.setState((state: any) => ({ ...state, mouseDown: false })); + }, + + start() { + addContextMenuPatch("image-context", imageContextMenuPatch); + this.element = document.createElement("div"); + this.element.classList.add("MagnifierContainer"); + document.body.appendChild(this.element); + }, + + stop() { + // so componenetWillUnMount gets called if Magnifier component is still alive + this.root && this.root.unmount(); + this.element?.remove(); + removeContextMenuPatch("image-context", imageContextMenuPatch); + } +}); diff --git a/src/plugins/imageZoom/styles.css b/src/plugins/imageZoom/styles.css new file mode 100644 index 00000000..103ac548 --- /dev/null +++ b/src/plugins/imageZoom/styles.css @@ -0,0 +1,31 @@ +.lens { + position: absolute; + inset: 0; + z-index: 9999; + border: 2px solid grey; + border-radius: 50%; + overflow: hidden; + cursor: none; + box-shadow: inset 0 0 10px 2px grey; + filter: drop-shadow(0 0 2px grey); + pointer-events: none; +} + +.zoom img { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* make the carousel take up less space so we can click the backdrop and exit out of it */ +[class^="focusLock"] > [class^="carouselModal"] { + height: fit-content; + box-shadow: none; +} + +[class^="focusLock"] > [class^="carouselModal"] > div { + height: fit-content; + top: 50%; + transform: translateY(-50%); +} diff --git a/src/plugins/imageZoom/utils/waitFor.ts b/src/plugins/imageZoom/utils/waitFor.ts new file mode 100644 index 00000000..120aec05 --- /dev/null +++ b/src/plugins/imageZoom/utils/waitFor.ts @@ -0,0 +1,22 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +export function waitFor(condition: () => boolean, cb: () => void) { + if (condition()) cb(); + else requestAnimationFrame(() => waitFor(condition, cb)); +}