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
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { NavContextMenuPatchCallback } from "@api/ContextMenu";8
import { definePluginSettings } from "@api/Settings";9
import { debounce } from "@shared/debounce";10
import { Devs } from "@utils/constants";11
import { Logger } from "@utils/Logger";12
import definePlugin, { OptionType } from "@utils/types";13
import { createRoot, Menu } from "@webpack/common";14
import { JSX } from "react";15
import type { Root } from "react-dom/client";16
17
import { Magnifier, MagnifierProps } from "./components/Magnifier";18
import { ELEMENT_ID } from "./constants";19
import managedStyle from "./styles.css?managed";20
21
export 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
71
const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {72
// Discord re-uses the image context menu for links to for the copy and open buttons73
if ("href" in props) return;74
// emojis in user statuses75
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.MenuCheckboxItem82
id="vc-square"83
label="Square Lens"84
checked={square}85
action={() => {86
settings.store.square = !square;87
}}88
/>89
<Menu.MenuCheckboxItem90
id="vc-nearest-neighbour"91
label="Nearest Neighbour"92
checked={nearestNeighbour}93
action={() => {94
settings.store.nearestNeighbour = !nearestNeighbour;95
}}96
/>97
<Menu.MenuControlItem98
id="vc-zoom"99
label="Zoom"100
control={(props, ref) => (101
<Menu.MenuSliderControl102
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.MenuControlItem112
id="vc-size"113
label="Lens Size"114
control={(props, ref) => (115
<Menu.MenuSliderControl116
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.MenuControlItem126
id="vc-zoom-speed"127
label="Zoom Speed"128
control={(props, ref) => (129
<Menu.MenuSliderControl130
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
144
export 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 compat163
replace: "039;div039;,{onClick:e=>e.stopPropagation(),children:$1"164
}165
]166
},167
// Make media viewer options not hide when zoomed in with the default Discord feature168
{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": imageContextMenuPatch205
},206
207
// to stop from rendering twice /shrug208
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 alive270
this.root && this.root.unmount();271
this.element?.remove();272
}273
});274