aboutsummaryrefslogtreecommitdiff
path: root/src/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'src/plugins')
-rw-r--r--src/plugins/imageZoom/components/Magnifier.tsx198
-rw-r--r--src/plugins/imageZoom/constants.ts19
-rw-r--r--src/plugins/imageZoom/index.tsx234
-rw-r--r--src/plugins/imageZoom/styles.css31
-rw-r--r--src/plugins/imageZoom/utils/waitFor.ts22
5 files changed, 504 insertions, 0 deletions
diff --git a/src/plugins/imageZoom/components/Magnifier.tsx b/src/plugins/imageZoom/components/Magnifier.tsx
new file mode 100644
index 0000000..e61c560
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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<MagnifierProps> = ({ instance, size: initialSize, zoom: initalZoom }) => {
+ const [ready, setReady] = useState(false);
+
+
+ const [lensPosition, setLensPosition] = useState<Vec2>({ x: 0, y: 0 });
+ const [imagePosition, setImagePosition] = useState<Vec2>({ x: 0, y: 0 });
+ const [opacity, setOpacity] = useState(0);
+
+ const isShiftDown = useRef(false);
+
+ const zoom = useRef(initalZoom);
+ const size = useRef(initialSize);
+
+ const element = useRef<HTMLDivElement | null>(null);
+ const currentVideoElementRef = useRef<HTMLVideoElement | null>(null);
+ const originalVideoElementRef = useRef<HTMLVideoElement | null>(null);
+ const imageRef = useRef<HTMLImageElement | null>(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 (
+ <div
+ className="lens"
+ style={{
+ opacity,
+ width: size.current + "px",
+ height: size.current + "px",
+ transform: `translate(${lensPosition.x}px, ${lensPosition.y}px)`,
+ }}
+ >
+ {instance.props.animated ?
+ (
+ <video
+ ref={currentVideoElementRef}
+ style={{
+ position: "absolute",
+ left: `${imagePosition.x}px`,
+ top: `${imagePosition.y}px`
+ }}
+ width={`${box.width * zoom.current}px`}
+ height={`${box.height * zoom.current}px`}
+ poster={instance.props.src}
+ src={originalVideoElementRef.current?.src ?? instance.props.src}
+ autoPlay
+ loop
+ />
+ ) : (
+ <img
+ ref={imageRef}
+ style={{
+ position: "absolute",
+ transform: `translate(${imagePosition.x}px, ${imagePosition.y}px)`
+ }}
+ width={`${box.width * zoom.current}px`}
+ height={`${box.height * zoom.current}px`}
+ src={instance.props.src} alt=""
+ />
+ )}
+ </div>
+ );
+};
diff --git a/src/plugins/imageZoom/constants.ts b/src/plugins/imageZoom/constants.ts
new file mode 100644
index 0000000..cfde60c
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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 0000000..7a1887b
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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(
+ <Menu.MenuGroup id="image-zoom">
+ {/* thanks SpotifyControls */}
+ <Menu.MenuControlItem
+ id="zoom"
+ label="Zoom"
+ control={(props, ref) => (
+ <Menu.MenuSliderControl
+ ref={ref}
+ {...props}
+ minValue={1}
+ maxValue={50}
+ value={settings.store.zoom}
+ onChange={debounce((value: number) => settings.store.zoom = value, 100)}
+ />
+ )}
+ />
+ <Menu.MenuControlItem
+ id="size"
+ label="Lens Size"
+ control={(props, ref) => (
+ <Menu.MenuSliderControl
+ ref={ref}
+ {...props}
+ minValue={50}
+ maxValue={1000}
+ value={settings.store.size}
+ onChange={debounce((value: number) => settings.store.size = value, 100)}
+ />
+ )}
+ />
+ <Menu.MenuControlItem
+ id="zoom-speed"
+ label="Zoom Speed"
+ control={(props, ref) => (
+ <Menu.MenuSliderControl
+ ref={ref}
+ {...props}
+ minValue={0.1}
+ maxValue={5}
+ value={settings.store.zoomSpeed}
+ onChange={debounce((value: number) => settings.store.zoomSpeed = value, 100)}
+ renderValue={(value: number) => `${value.toFixed(3)}x`}
+ />
+ )}
+ />
+ </Menu.MenuGroup>
+ );
+ }
+};
+
+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<MagnifierProps & JSX.IntrinsicAttributes> | 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 = <Magnifier size={settings.store.size} zoom={settings.store.zoom} instance={instance} />;
+ 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 0000000..103ac54
--- /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 0000000..120aec0
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+export function waitFor(condition: () => boolean, cb: () => void) {
+ if (condition()) cb();
+ else requestAnimationFrame(() => waitFor(condition, cb));
+}