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
import { NavContextMenuPatchCallback } from "@api/ContextMenu";2
import { definePluginSettings } from "@api/Settings";3
import { debounce } from "@shared/debounce";4
import { Devs } from "@utils/constants";5
import { Logger } from "@utils/Logger";6
import definePlugin, { OptionType } from "@utils/types";7
import { createRoot, Menu } from "@webpack/common";8
import { JSX } from "react";9
import type { Root } from "react-dom/client";10
11
import { Magnifier, MagnifierProps } from "./components/Magnifier";12
import { ELEMENT_ID } from "./constants";13
import managedStyle from "./styles.css?managed";14
15
export 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
65
const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {66
// Discord re-uses the image context menu for links to for the copy and open buttons67
if ("href" in props) return;68
// emojis in user statuses69
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.MenuCheckboxItem76
id="vc-square"77
label="Square Lens"78
checked={square}79
action={() => {80
settings.store.square = !square;81
}}82
/>83
<Menu.MenuCheckboxItem84
id="vc-nearest-neighbour"85
label="Nearest Neighbour"86
checked={nearestNeighbour}87
action={() => {88
settings.store.nearestNeighbour = !nearestNeighbour;89
}}90
/>91
<Menu.MenuControlItem92
id="vc-zoom"93
label="Zoom"94
control={(props, ref) => (95
<Menu.MenuSliderControl96
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.MenuControlItem106
id="vc-size"107
label="Lens Size"108
control={(props, ref) => (109
<Menu.MenuSliderControl110
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.MenuControlItem120
id="vc-zoom-speed"121
label="Zoom Speed"122
control={(props, ref) => (123
<Menu.MenuSliderControl124
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
138
export 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 compat157
replace: "039;div039;,{onClick:e=>e.stopPropagation(),children:$1"158
}159
]160
},161
// Make media viewer options not hide when zoomed in with the default Discord feature162
{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": imageContextMenuPatch199
},200
201
// to stop from rendering twice /shrug202
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 alive264
this.root && this.root.unmount();265
this.element?.remove();266
}267
});268