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