Plugin

ImageZoom

Lets you zoom in to images and gifs. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius / size

Media Utility
index.tsx
Download

Source

src/plugins/imageZoom/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { NavContextMenuPatchCallback } from "@api/ContextMenu";
8import { definePluginSettings } from "@api/Settings";
9import { debounce } from "@shared/debounce";
10import { Devs } from "@utils/constants";
11import { Logger } from "@utils/Logger";
12import definePlugin, { OptionType } from "@utils/types";
13import { createRoot, Menu } from "@webpack/common";
14import { JSX } from "react";
15import type { Root } from "react-dom/client";
16
17import { Magnifier, MagnifierProps } from "./components/Magnifier";
18import { ELEMENT_ID } from "./constants";
19import managedStyle from "./styles.css?managed";
20
21export const settings = definePluginSettings({
22 saveZoomValues: {
23 type: OptionType.BOOLEAN,
24 description: "Whether to save zoom and lens size values",
25 default: true,
26 },
27
28 invertScroll: {
29 type: OptionType.BOOLEAN,
30 description: "Invert scroll",
31 default: true,
32 },
33
34 nearestNeighbour: {
35 type: OptionType.BOOLEAN,
36 description: "Use Nearest Neighbour Interpolation when scaling images",
37 default: false,
38 },
39
40 square: {
41 type: OptionType.BOOLEAN,
42 description: "Make the lens square",
43 default: false,
44 },
45
46 zoom: {
47 description: "Zoom of the lens",
48 type: OptionType.SLIDER,
49 markers: [1, 5, 10, 20, 30, 40, 50],
50 default: 2,
51 stickToMarkers: false,
52 },
53 size: {
54 description: "Radius / Size of the lens",
55 type: OptionType.SLIDER,
56 markers: [50, 100, 250, 500, 750, 1000],
57 default: 100,
58 stickToMarkers: false,
59 },
60
61 zoomSpeed: {
62 description: "How fast the zoom / lens size changes",
63 type: OptionType.SLIDER,
64 markers: [0.1, 0.5, 1, 2, 3, 4, 5],
65 default: 0.5,
66 stickToMarkers: false,
67 },
68});
69
70
71const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
72 // Discord re-uses the image context menu for links to for the copy and open buttons
73 if ("href" in props) return;
74 // emojis in user statuses
75 if (props.target?.classList?.contains("emoji")) return;
76
77 const { square, nearestNeighbour } = settings.use(["square", "nearestNeighbour"]);
78
79 children.push(
80 <Menu.MenuGroup id="image-zoom">
81 <Menu.MenuCheckboxItem
82 id="vc-square"
83 label="Square Lens"
84 checked={square}
85 action={() => {
86 settings.store.square = !square;
87 }}
88 />
89 <Menu.MenuCheckboxItem
90 id="vc-nearest-neighbour"
91 label="Nearest Neighbour"
92 checked={nearestNeighbour}
93 action={() => {
94 settings.store.nearestNeighbour = !nearestNeighbour;
95 }}
96 />
97 <Menu.MenuControlItem
98 id="vc-zoom"
99 label="Zoom"
100 control={(props, ref) => (
101 <Menu.MenuSliderControl
102 ref={ref}
103 {...props}
104 minValue={1}
105 maxValue={50}
106 value={settings.store.zoom}
107 onChange={debounce((value: number) => settings.store.zoom = value, 100)}
108 />
109 )}
110 />
111 <Menu.MenuControlItem
112 id="vc-size"
113 label="Lens Size"
114 control={(props, ref) => (
115 <Menu.MenuSliderControl
116 ref={ref}
117 {...props}
118 minValue={50}
119 maxValue={1000}
120 value={settings.store.size}
121 onChange={debounce((value: number) => settings.store.size = value, 100)}
122 />
123 )}
124 />
125 <Menu.MenuControlItem
126 id="vc-zoom-speed"
127 label="Zoom Speed"
128 control={(props, ref) => (
129 <Menu.MenuSliderControl
130 ref={ref}
131 {...props}
132 minValue={0.1}
133 maxValue={5}
134 value={settings.store.zoomSpeed}
135 onChange={debounce((value: number) => settings.store.zoomSpeed = value, 100)}
136 renderValue={(value: number) => `${value.toFixed(3)}x`}
137 />
138 )}
139 />
140 </Menu.MenuGroup>
141 );
142};
143
144export default definePlugin({
145 name: "ImageZoom",
146 description: "Lets you zoom in to images and gifs. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius / size",
147 tags: ["Media", "Utility"],
148 authors: [Devs.Aria],
149 searchTerms: ["ImageUtilities"],
150
151 managedStyle,
152
153 patches: [
154 {
155 find: "disableArrowKeySeek:!0",
156 replacement: [
157 {
158 match: /useFullWidth:!0,shouldLink:/,
159 replace: `id:"${ELEMENT_ID}",$&`
160 },
161 {
162 match: /(?<=null!=(\i)\?.{0,20})\i(?:\.\i)?,{children:\1/, class="ts-cmt">// TODO: (?:\.\i)? is stable compat
163 replace: "&#039;div&#039;,{onClick:e=>e.stopPropagation(),children:$1"
164 }
165 ]
166 },
167 // Make media viewer options not hide when zoomed in with the default Discord feature
168 {
169 find: &#039;="FOCUS_SENSITIVE",&#039;,
170 replacement: {
171 match: /(?<=\[\i\.\i]:)\i&&!\i&&"PINNED"!==\i/,
172 replace: "false"
173 }
174 },
175
176 {
177 find: ".handleImageLoad)",
178 replacement: [
179 {
180 match: /placeholderVersion:\i,(?=.{0,50}children:)/,
181 replace: "...$self.makeProps(this),$&"
182 },
183
184 {
185 match: /componentDidMount\(\){/,
186 replace: "$&$self.renderMagnifier(this);",
187 },
188
189 {
190 match: /componentWillUnmount\(\){/,
191 replace: "$&$self.unMountMagnifier();"
192 },
193
194 {
195 match: /componentDidUpdate\(\i\){/,
196 replace: "$&$self.updateMagnifier(this);"
197 }
198 ]
199 }
200 ],
201
202 settings,
203 contextMenus: {
204 "image-context": imageContextMenuPatch
205 },
206
207 // to stop from rendering twice /shrug
208 currentMagnifierElement: null as React.FunctionComponentElement<MagnifierProps & JSX.IntrinsicAttributes> | null,
209 element: null as HTMLDivElement | null,
210
211 Magnifier,
212 root: null as Root | null,
213 makeProps(instance) {
214 return {
215 onMouseOver: () => this.onMouseOver(instance),
216 onMouseOut: () => this.onMouseOut(instance),
217 onMouseDown: (e: React.MouseEvent) => this.onMouseDown(e, instance),
218 onMouseUp: () => this.onMouseUp(instance),
219 id: instance.props.id,
220 };
221 },
222
223 renderMagnifier(instance) {
224 try {
225 if (instance.props.id === ELEMENT_ID) {
226 if (!this.currentMagnifierElement) {
227 this.currentMagnifierElement = <Magnifier size={settings.store.size} zoom={settings.store.zoom} instance={instance} />;
228 this.root = createRoot(this.element!);
229 this.root.render(this.currentMagnifierElement);
230 }
231 }
232 } catch (error) {
233 new Logger("ImageZoom").error("Failed to render magnifier:", error);
234 }
235 },
236
237 updateMagnifier(instance) {
238 this.unMountMagnifier();
239 this.renderMagnifier(instance);
240 },
241
242 unMountMagnifier() {
243 this.root?.unmount();
244 this.currentMagnifierElement = null;
245 this.root = null;
246 },
247
248 onMouseOver(instance) {
249 instance.setState((state: any) => ({ ...state, mouseOver: true }));
250 },
251 onMouseOut(instance) {
252 instance.setState((state: any) => ({ ...state, mouseOver: false }));
253 },
254 onMouseDown(e: React.MouseEvent, instance) {
255 if (e.button === 0 /* left */)
256 instance.setState((state: any) => ({ ...state, mouseDown: true }));
257 },
258 onMouseUp(instance) {
259 instance.setState((state: any) => ({ ...state, mouseDown: false }));
260 },
261
262 start() {
263 this.element = document.createElement("div");
264 this.element.classList.add("MagnifierContainer");
265 document.body.appendChild(this.element);
266 },
267
268 stop() {
269 // so componenetWillUnMount gets called if Magnifier component is still alive
270 this.root && this.root.unmount();
271 this.element?.remove();
272 }
273});
274