Add Betterimageeditor Plugin (#1336)

* BetterImageEditor: keep your own pictures in the image picker and cropper

Discord archives past avatars only after cropping and keeps nothing for
banners. This holds the file you chose before the cropper touches it,
remembers how you framed it, and puts both on a shelf in the picker and
under the crop window.

* Take over Discord's recent avatars slot instead of sitting above it

* Trim comments

* Do not offer a cropped copy of a picture that already is one

* List every image slot in the description

* Drop decorations from the description

* Serialise library writes and tighten the editor types

Index writers now take turns, so saving a crop and adding its cropped copy
cannot overwrite each other. Imported cropped copies point at their new
source ids. A picture only counts as worn once the cropper confirms it, and
reset or close clears it. Classes use the vc-bie- prefix and the editor
props are typed instead of any.

* Listen for profile events through the flux field

* Move the shelf into its own file and drop the comments

* Run clear through the write queue

* Fixed another bunch of bugs because I am stupid.

* Keep imported crop links only when they point somewhere real

A cropped picture remembers the id of the original it was cut from. On import
that id used to be kept as-is whenever the original was not in the file, but an
id from someone else's library matches nothing here, so cropping the same
picture again piled up another copy instead of replacing the previous one. The
link now survives only when it remaps to an imported picture or already names an
entry in this library.

* the last bunch of fixes, so Reptile isn’t screaming at me anymore

* Tie a cropped picture to its original by signature

The link stored the original's id, and ids die when a picture is trimmed off
the shelf, forgotten, or carried into another library. A crop left pointing at a
dead id no longer matched its original, so re-cropping stacked up copies instead
of replacing the previous one. A signature is the same for the same bytes, so
the link now survives all three, and the import no longer has to remap ids.

* Keep crop links working when the library predates the signature change

Old libraries and old exports store the original's id in from, which stopped matching anything once crops were linked by signature. Imports now translate an id back to its signature, and add() accepts either.

* Drop a crop's source link when the import can't resolve it

An old file can hold a crop whose original was left out, and there is nothing in the file that says which picture that id was. Keeping it stored a link that could never match. A signature is recognisable on sight, so anything that isn't one and isn't in the file is dropped.

* Update index.tsx

* Update plugin description for BetterImageEditor

* Update index.tsx

* Removal of the query selector. Now the plugin shouldn't have any

* Update src/equicordplugins/betterImageEditor/index.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Use pluralise for the toast counts

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
Co-authored-by: Just_Me <github@angel-dust.8shield.net>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Just_Me 2026-09-13 19:58:56 +02:00 • committed by GitHub
parent f74a0e8b64
commit 9d2a1e196a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1266 additions and 0 deletions

View file

@ -0,0 +1,152 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DeleteIcon } from "@components/Icons";
import { classNameFactory } from "@utils/css";
import { React, useCallback, useEffect, useRef, useState } from "@webpack/common";
import { Entry, getFile, Group, Kind } from "../library";
export const cl = classNameFactory("vc-bie-");
const SQUARE = new Set(["AVATAR", "AVATAR_DECORATION", "GUILD_ICON", "PERSONAL_WIDGET_FIELD"]);
const KIND_NAMES: Record<string, string> = {
AVATAR: "avatars",
BANNER: "banners",
GUILD_ICON: "server icons",
GUILD_BANNER: "server banners",
SCHEDULED_EVENT_IMAGE: "event covers",
HOME_HEADER: "home headers",
AVATAR_DECORATION: "decorations",
PERSONAL_WIDGET_COVER: "widget covers",
PERSONAL_WIDGET_FIELD: "widget images"
};
const kindName = (kind: Kind) => KIND_NAMES[kind] ?? kind.toLowerCase().replace(/_/g, " ");
const isAnimated = (entry: Entry) => entry.type === "image/gif" || entry.name.toLowerCase().endsWith(".gif");
export const croppedLabel = (kind: Kind) => `Cropped ${kindName(kind)}`;
const PinIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M15 4V9l3 3v2h-5v7l-1 1-1-1v-7H6v-2l3-3V4H8V2h8v2z" />
</svg>
);
export function Shelf({ kind, group, entries, thumbs, activeId, wornId, onGroup, onPick, onPin, onForget, onPutBack }: {
kind: Kind;
group: Group;
entries: Entry[];
thumbs: Record<string, string>;
activeId: string | null;
wornId: string | null;
onGroup(group: Group): void;
onPick(entry: Entry): void;
onPin(entry: Entry): void;
onForget(entry: Entry, immediate: boolean): void;
onPutBack?(): void;
}) {
const shape = SQUARE.has(kind) ? "square" : "wide";
const [hovered, setHovered] = useState<string | null>(null);
const [playing, setPlaying] = useState<Record<string, string>>({});
const played = useRef<string[]>([]);
useEffect(() => () => played.current.forEach(URL.revokeObjectURL), []);
const play = useCallback(async (entry: Entry) => {
setHovered(entry.id);
if (!isAnimated(entry) || playing[entry.id]) return;
const blob = await getFile(entry.id);
if (!blob) return;
const url = URL.createObjectURL(blob);
played.current.push(url);
setPlaying(current => ({ ...current, [entry.id]: url }));
}, [playing]);
return (
<div className={cl("panel", shape)}>
<div className={cl("tabs")} role="tablist">
<button
type="button"
role="tab"
aria-selected={group === "original"}
className={cl("tab", { on: group === "original" })}
onClick={() => onGroup("original")}
>Originals</button>
<button
type="button"
role="tab"
aria-selected={group === "cropped"}
className={cl("tab", { on: group === "cropped" })}
onClick={() => onGroup("cropped")}
>{croppedLabel(kind)}</button>
{onPutBack && (
<button type="button" className={cl("action")} onClick={onPutBack}>
Put back the last one
</button>
)}
</div>
<div className={cl("strip")} role="group" aria-label="Saved pictures">
{entries.map(entry => (
<div
key={entry.id}
className={cl("item", { pinned: entry.pinned, worn: entry.id === wornId })}
onMouseEnter={() => play(entry)}
onMouseLeave={() => setHovered(null)}
>
<button
type="button"
aria-label={entry.name}
title={entry.id === wornId ? `${entry.name}
You are wearing this` : entry.name}
className={cl("thumb", { active: activeId === entry.id })}
style={{ backgroundImage: `url(${(hovered === entry.id && playing[entry.id]) || thumbs[entry.id]})` }}
onClick={() => onPick(entry)}
onContextMenu={event => {
event.preventDefault();
onForget(entry, event.shiftKey);
}}
/>
<button
type="button"
className={cl("remove")}
aria-label={`Remove ${entry.name}`}
title="Remove. Hold Shift to skip the confirmation"
onClick={event => onForget(entry, event.shiftKey)}
>
<DeleteIcon width={10} height={10} />
</button>
<button
type="button"
aria-pressed={!!entry.pinned}
className={cl("pin", { on: entry.pinned })}
aria-label={entry.pinned ? `Unpin ${entry.name}` : `Pin ${entry.name}`}
title={entry.pinned ? "Pinned, so it never drops off the shelf" : "Pin so it never drops off the shelf"}
onClick={() => onPin(entry)}
>
<PinIcon width={10} height={10} />
</button>
</div>
))}
{!entries.length && (
<span className={cl("empty")}>
{group === "original"
? "Pictures you pick, paste or drop land here"
: "Pictures you crop land here"}
</span>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,615 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import { pluralise } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { chooseFile, saveFile } from "@utils/web";
import { Alerts, Button, React, showToast, Toasts, useCallback, useEffect, useRef, useState } from "@webpack/common";
import { cl, croppedLabel, Shelf } from "./components/Shelf";
import { add, clear, CropState, currentApplied, Entry, exportAll, forget, forgetCrops, getFile, getThumbs, Group, importAll, Kind, previousApplied, readIndex, recordApplied, saveCrop, toDataUrl, togglePin, touch } from "./library";
interface PickResult {
imageUri: string;
file: File;
}
interface CropResult extends PickResult {
staticImageUri?: string;
transform: CropState;
}
interface PickerProps {
allowRecentAvatarsSelection?: boolean;
maxFileSizeBytes?: number;
}
interface PendingChanges {
guildId?: string;
pendingAvatar?: { imageUri: string; } | null;
pendingBanner?: { imageUri: string; } | null;
}
interface EditorProps {
file?: File;
imageUri?: string;
originalAsset?: unknown;
uploadType?: string;
initialTransform?: CropState | null;
onCrop?(result: CropResult): unknown;
bieShelf?: React.ReactNode;
}
interface Picked {
id: string;
uri: string;
file: File;
group: Group;
}
const logger = new Logger("BetterImageEditor");
const settings = definePluginSettings({
librarySize: {
type: OptionType.SLIDER,
description: "How many pictures to keep on each shelf.",
markers: [6, 12, 24, 48],
default: 24,
stickToMarkers: true
},
rememberCrop: {
type: OptionType.BOOLEAN,
description: "Restore the zoom and position you last used for a picture.",
default: true
},
forgetFraming: {
type: OptionType.COMPONENT,
description: "Drop every remembered crop and open each picture the way Discord would.",
component: () => (
<Button
color={Button.Colors.PRIMARY}
onClick={() => Alerts.show({
title: "Forget every remembered crop?",
body: "Your pictures stay. Each one opens unframed from now on, until you crop it again.",
confirmColor: Button.Colors.RED,
confirmText: "Forget",
cancelText: "Cancel",
onConfirm: () => forgetCrops()
.then(count => showToast(
count ? `Forgot the framing on ${pluralise(count, "picture")}` : "Nothing was framed",
Toasts.Type.SUCCESS
))
.catch(err => logger.error("could not forget the remembered crops", err))
})}
>
Forget remembered framing
</Button>
)
},
saveCropped: {
type: OptionType.BOOLEAN,
description: "Keep a copy of each picture after you crop it.",
default: true
},
askBeforeSavingCropped: {
type: OptionType.BOOLEAN,
description: "Ask first, instead of keeping the cropped copy automatically.",
default: false
},
transfer: {
type: OptionType.COMPONENT,
description: "Carry your pictures, and how each one is framed, to another device.",
component: () => (
<div className={cl("buttons")}>
<Button color={Button.Colors.PRIMARY} onClick={exportLibrary}>Export to a file</Button>
<Button color={Button.Colors.PRIMARY} onClick={importLibrary}>Import from a file</Button>
</div>
)
},
clearLibrary: {
type: OptionType.COMPONENT,
description: "Throw away every picture you have saved.",
component: () => (
<Button
color={Button.Colors.PRIMARY}
onClick={() => Alerts.show({
title: "Clear saved pictures?",
body: "Every picture you have saved here goes, along with the crop remembered for each one. Discord's own archive and your current avatar and banner are not touched.",
confirmColor: Button.Colors.RED,
confirmText: "Clear",
cancelText: "Cancel",
onConfirm: () => clear().catch(err => logger.error("could not clear the library", err))
})}
>
Clear saved pictures
</Button>
)
}
});
let handoff: { id: string; transform: CropState | null; file: File; } | null = null;
let offered: { kind: Kind; id: string; uris: (string | undefined)[]; } | null = null;
const handedOver = new Map<string, string | null>();
let editorAllowed = true;
const PENDING = [["AVATAR", "pendingAvatar"], ["BANNER", "pendingBanner"]] as const;
function offer(kind: Kind, id: string, uris: (string | undefined)[]) {
offered = { kind, id, uris };
}
const kindOf = (uploadType?: string): Kind => uploadType || "AVATAR";
const EXTENSIONS: Record<string, string> = {
"image/gif": "gif",
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp"
};
const FILENAME = "better-image-editor.json";
async function exportLibrary() {
try {
const data = new TextEncoder().encode(await exportAll());
if (IS_DISCORD_DESKTOP) DiscordNative.fileManager.saveWithDialog(data, FILENAME);
else saveFile(new File([data], FILENAME, { type: "application/json" }));
} catch (err) {
logger.error("could not export the library", err);
showToast("Could not export your pictures", Toasts.Type.FAILURE);
}
}
async function readChosenFile() {
if (!IS_DISCORD_DESKTOP) return (await chooseFile("application/json"))?.text() ?? null;
const [file] = await DiscordNative.fileManager.openFiles({
filters: [{ name: "Picture library", extensions: ["json"] }]
});
return file ? new TextDecoder().decode(file.data) : null;
}
async function importLibrary() {
try {
const json = await readChosenFile();
if (!json) return;
const added = await importAll(json, settings.store.librarySize);
showToast(
added ? `Added ${pluralise(added, "picture")}` : "Nothing new in that file",
Toasts.Type.SUCCESS
);
} catch (err) {
logger.error("could not import the library", err);
showToast("Could not read that file", Toasts.Type.FAILURE);
}
}
function useLibrary(kind: Kind) {
const [group, setGroup] = useState<Group>("original");
const [version, setVersion] = useState(0);
const [entries, setEntries] = useState<Entry[]>([]);
const [thumbs, setThumbs] = useState<Record<string, string>>({});
const [worn, setWorn] = useState<string | null>(null);
const bump = useCallback(() => setVersion(v => v + 1), []);
useEffect(() => () => Object.values(thumbs).forEach(URL.revokeObjectURL), [thumbs]);
useEffect(() => {
let live = true;
(async () => {
const mine = (await readIndex())
.filter(entry => entry.kind === kind && entry.group === group)
.sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || (b.used ?? b.added) - (a.used ?? a.added));
const blobs = await getThumbs(mine.map(entry => entry.id));
if (!live) return;
const next: Record<string, string> = {};
mine.forEach((entry, index) => {
const blob = blobs[index];
if (blob) next[entry.id] = URL.createObjectURL(blob);
});
setThumbs(next);
setEntries(mine);
})();
return () => { live = false; };
}, [kind, group, version]);
useEffect(() => {
currentApplied(kind)
.then(setWorn)
.catch(err => logger.error("could not read what you are wearing", err));
}, [kind, version]);
return { group, setGroup, entries, thumbs, worn, bump };
}
function useForget(bump: () => void) {
return useCallback((entry: Entry, immediate: boolean) => {
const run = () => forget(entry.id)
.then(bump)
.catch(err => logger.error("could not remove that picture", err));
if (immediate) return void run();
Alerts.show({
title: "Remove this picture?",
body: `${entry.name} goes from your saved pictures, along with the crop remembered for it.`,
confirmColor: Button.Colors.RED,
confirmText: "Remove",
cancelText: "Cancel",
onConfirm: run
});
}, [bump]);
}
let editorsOpen = 0;
function usePasteAndDrop(accept: (file: File) => void, active: () => boolean) {
useEffect(() => {
const imageIn = (list: FileList | undefined) =>
[...(list ?? [])].find(file => file.type.startsWith("image/"));
const take = (file: File | undefined, event: Event) => {
if (!file || !active()) return;
event.preventDefault();
event.stopPropagation();
accept(file);
};
const onPaste = (event: ClipboardEvent) => take(imageIn(event.clipboardData?.files), event);
const onDrop = (event: DragEvent) => take(imageIn(event.dataTransfer?.files), event);
const onDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes("Files")) return;
event.preventDefault();
event.stopPropagation();
};
document.addEventListener("paste", onPaste, true);
document.addEventListener("drop", onDrop, true);
document.addEventListener("dragover", onDragOver, true);
return () => {
document.removeEventListener("paste", onPaste, true);
document.removeEventListener("drop", onDrop, true);
document.removeEventListener("dragover", onDragOver, true);
};
}, [accept, active]);
}
function EditorGate() {
useEffect(() => {
editorAllowed = false;
return () => { editorAllowed = true; };
}, []);
return null;
}
function PickerShelf({ kind, open, complete, maxSize }: {
kind: Kind;
open(imageUri: string, file: File): void;
complete(result: PickResult): void;
maxSize?: number;
}) {
const { group, setGroup, entries, thumbs, worn, bump } = useLibrary(kind);
const onForget = useForget(bump);
const hand = useCallback(async (id: string, file: File, crop: CropState | null) => {
handoff = { id, transform: crop, file };
open(await toDataUrl(file), file);
}, [open]);
const [putBack, setPutBack] = useState<Entry | null>(null);
useEffect(() => {
previousApplied(kind)
.then(setPutBack)
.catch(err => logger.error("could not read what you wore before", err));
}, [kind]);
const onPick = useCallback(async (entry: Entry) => {
const blob = await getFile(entry.id);
if (!blob) return;
const file = new File([blob], entry.name, { type: blob.type });
await touch(entry.id);
bump();
if (entry.group === "cropped") {
const uri = await toDataUrl(blob);
offer(kind, entry.id, [uri]);
return complete({ imageUri: uri, file });
}
await hand(entry.id, file, settings.store.rememberCrop ? entry.crop ?? null : null);
}, [hand, complete, kind, bump]);
const onPin = useCallback((entry: Entry) => {
togglePin(entry.id).then(bump).catch(err => logger.error("could not pin that picture", err));
}, [bump]);
const accept = useCallback(async (file: File) => {
if (maxSize && file.size > maxSize) return showToast("That picture is too big for Discord", Toasts.Type.FAILURE);
try {
const entry = await add(file, kind, "original", settings.store.librarySize);
bump();
await hand(entry.id, file, settings.store.rememberCrop ? entry.crop ?? null : null);
} catch (err) {
logger.error("could not take that picture", err);
}
}, [kind, hand, bump, maxSize]);
usePasteAndDrop(accept, useCallback(() => editorsOpen === 0, []));
return (
<Shelf
kind={kind}
group={group}
entries={entries}
thumbs={thumbs}
activeId={null}
wornId={worn}
onGroup={setGroup}
onPick={onPick}
onPin={onPin}
onForget={onForget}
onPutBack={putBack ? () => onPick(putBack) : undefined}
/>
);
}
function EditorShelf({ Original, ownProps }: { Original: React.ComponentType<EditorProps>; ownProps: EditorProps; }) {
const kind = kindOf(ownProps.uploadType);
const { group, setGroup, entries, thumbs, worn, bump } = useLibrary(kind);
const onForget = useForget(bump);
const [picked, setPicked] = useState<Picked | null>(null);
const [transform, setTransform] = useState<CropState | null>(() => handoff && handoff.file === ownProps.file ? handoff.transform : null);
const incomingId = useRef<string | null>(null);
const pickedId = useRef<string | null>(null);
const pickedName = useRef<string | null>(null);
const pickedGroup = useRef<Group>("original");
pickedId.current = picked?.id ?? null;
pickedGroup.current = picked?.group ?? "original";
pickedName.current = picked?.file.name ?? ownProps.file?.name ?? null;
const show = useCallback(async (id: string, file: File, crop: CropState | null, group: Group = "original") => {
setTransform(crop);
setPicked({ id, uri: await toDataUrl(file), file, group });
}, []);
useEffect(() => {
const incoming = handoff;
handoff = null;
if (incoming && incoming.file === ownProps.file) {
incomingId.current = incoming.id;
return;
}
const { file } = ownProps;
if (!(file instanceof File) || !file.type.startsWith("image/")) return;
add(file, kind, "original", settings.store.librarySize)
.then(entry => {
incomingId.current = entry.id;
bump();
if (entry.crop && settings.store.rememberCrop) return show(entry.id, file, entry.crop);
})
.catch(err => logger.error("could not save that picture", err));
}, []);
useEffect(() => {
editorsOpen++;
return () => { editorsOpen--; };
}, []);
const accept = useCallback(async (file: File) => {
try {
const entry = await add(file, kind, "original", settings.store.librarySize);
setGroup("original");
bump();
await show(entry.id, file, settings.store.rememberCrop ? entry.crop ?? null : null);
} catch (err) {
logger.error("could not take that picture", err);
}
}, [kind, show, bump, setGroup]);
usePasteAndDrop(accept, useCallback(() => true, []));
const onPick = useCallback(async (entry: Entry) => {
const blob = await getFile(entry.id);
if (!blob) return;
await touch(entry.id);
bump();
await show(entry.id, new File([blob], entry.name, { type: blob.type }), settings.store.rememberCrop ? entry.crop ?? null : null, entry.group);
}, [show, bump]);
const onPin = useCallback((entry: Entry) => {
togglePin(entry.id).then(bump).catch(err => logger.error("could not pin that picture", err));
}, [bump]);
const keepCropped = useCallback((result: CropResult) => {
const uri = result.imageUri;
const source = pickedId.current ?? incomingId.current ?? undefined;
const save = async () => {
const stem = (pickedName.current ?? "picture").replace(/\.[^.]+$/, "");
const blob = await fetch(uri).then(r => r.blob());
const name = `${stem} (cropped).${EXTENSIONS[blob.type] ?? "png"}`;
await add(new File([blob], name, { type: blob.type }), kind, "cropped", settings.store.librarySize, source);
bump();
};
const run = () => save().catch(err => logger.error("could not keep the cropped copy", err));
if (!settings.store.askBeforeSavingCropped) return void run();
Alerts.show({
title: "Keep the cropped copy?",
body: `It goes on the ${croppedLabel(kind).toLowerCase()} shelf, next to your originals.`,
confirmText: "Keep",
cancelText: "No thanks",
onConfirm: run
});
}, [kind, bump]);
const onCrop = useCallback((result: CropResult) => {
const id = pickedId.current ?? incomingId.current;
if (id) offer(kind, id, [result.imageUri, result.staticImageUri]);
if (id && result.transform && settings.store.rememberCrop) {
saveCrop(id, result.transform).catch(err => logger.error("could not remember that crop", err));
}
if (settings.store.saveCropped && pickedGroup.current !== "cropped") keepCropped(result);
return ownProps.onCrop?.(result);
}, [ownProps.onCrop, keepCropped, kind]);
const props = picked
? { ...ownProps, imageUri: picked.uri, file: picked.file, originalAsset: null, onCrop, initialTransform: transform }
: { ...ownProps, onCrop, initialTransform: transform ?? ownProps.initialTransform };
return (
<Original
key={picked?.id ?? "incoming"}
{...props}
bieShelf={
<ErrorBoundary noop>
<div>
<Shelf
kind={kind}
group={group}
entries={entries}
thumbs={thumbs}
activeId={picked?.id ?? null}
wornId={worn}
onGroup={setGroup}
onPick={onPick}
onPin={onPin}
onForget={onForget}
/>
</div>
</ErrorBoundary>
}
/>
);
}
function onPendingChanged({ guildId, ...changes }: PendingChanges) {
for (const [kind, field] of PENDING) {
if (!(field in changes)) continue;
const key = `${kind}:${guildId ?? ""}`;
const pending = changes[field];
if (pending === undefined) handedOver.delete(key);
else if (pending && offered?.kind === kind && offered.uris.includes(pending.imageUri)) handedOver.set(key, offered.id);
else handedOver.set(key, null);
}
}
function onProfileSaved({ guildId }: { guildId?: string; }) {
for (const [key, id] of handedOver) {
const [kind, guild] = key.split(":");
if (guild !== (guildId ?? "")) continue;
handedOver.delete(key);
if (!guildId) recordApplied(kind, id).catch(err => logger.error("could not note what you put on", err));
}
}
function onProfileDiscarded() {
handedOver.clear();
offered = null;
}
let wrapped: React.ComponentType<EditorProps> | null = null;
export default definePlugin({
name: "BetterImageEditor",
description: "Image editor with shelving for older images and allowing cropping",
authors: [EquicordDevs.heart_menace],
settings,
flux: {
USER_PROFILE_SETTINGS_SET_PENDING_CHANGES: onPendingChanged,
USER_PROFILE_SETTINGS_SUBMIT_SUCCESS: onProfileSaved,
USER_PROFILE_SETTINGS_RESET_PENDING_CHANGES: onProfileDiscarded,
USER_PROFILE_SETTINGS_RESET_PENDING_PROFILE_CHANGES: onProfileDiscarded,
USER_PROFILE_SETTINGS_RESET_AND_CLOSE_FORM: onProfileDiscarded
},
patches: [
{
find: '"SET_IMAGE_ZOOM_RATIO"',
replacement: {
match: /\{default:\(\)=>(\i)\}/,
replace: "{default:()=>$self.wrapEditor($1)}"
}
},
{
find: '"SET_IMAGE_ZOOM_RATIO"',
group: true,
replacement: [
{
match: /\{file:(\i),imageUri:/,
replace: "{bieShelf,file:$1,imageUri:"
},
{
match: /(\(0,\i\.jsx\)\(\i\.A,\{id:\i,children:)/,
replace: "bieShelf,$1"
}
]
},
{
find: 'displayName="RecentAvatarsStore"',
replacement: {
match: /(uploadType:(\i),guild:\i,handleOpenImageEditingModal:(\i),[\s\S]{0,500}?)\i&&\(0,\i\.jsx\)\(\i,\{onComplete:(\i),returnRef:\i\}\)/,
replace: "$1$self.pickerRow($2,$3,$4,arguments[0])"
}
}
],
wrapEditor(Original: React.ComponentType<EditorProps>) {
if (!wrapped) {
const Safe = ErrorBoundary.wrap(EditorShelf, {
fallback: ({ wrappedProps }) => <Original {...wrappedProps.ownProps} />
});
wrapped = (props: EditorProps) => editorAllowed
? <Safe Original={Original} ownProps={props} />
: <Original {...props} />;
}
return wrapped;
},
pickerRow(uploadType: string, open: (imageUri: string, file: File) => void, complete: (result: PickResult) => void, picker: PickerProps) {
if (picker.allowRecentAvatarsSelection === false) return <EditorGate key="bie-picker" />;
return (
<ErrorBoundary noop key="bie-picker">
<PickerShelf kind={kindOf(uploadType)} open={open} complete={complete} maxSize={picker.maxFileSizeBytes} />
</ErrorBoundary>
);
}
});

View file

@ -0,0 +1,278 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { nanoid } from "nanoid";
const store = DataStore.createStore("BetterImageEditor", "library");
export type Kind = string;
export type Group = "original" | "cropped";
export interface CropState {
zoomRatio: number;
imageRotation: number;
offsetRatio: { x: number; y: number; };
}
export interface Entry {
id: string;
name: string;
type?: string;
kind: Kind;
group: Group;
sig: string;
added: number;
crop?: CropState;
pinned?: boolean;
used?: number;
from?: string;
}
const INDEX = "index";
const fileKey = (id: string) => `file:${id}`;
const thumbKey = (id: string) => `thumb:${id}`;
const THUMB_MAX = 160;
export const readIndex = () => DataStore.get<Entry[]>(INDEX, store).then(entries => entries ?? []);
const writeIndex = (entries: Entry[]) => DataStore.set(INDEX, entries, store);
let turn: Promise<unknown> = Promise.resolve();
function queued<T>(work: () => Promise<T>) {
const result = turn.then(work);
turn = result.catch(() => { });
return result;
}
export const getFile = (id: string) => DataStore.get<Blob>(fileKey(id), store);
export const getThumb = (id: string) => DataStore.get<Blob>(thumbKey(id), store);
export const getThumbs = (ids: string[]) => DataStore.getMany<Blob>(ids.map(thumbKey), store);
export const clear = () => queued(() => DataStore.clear(store));
export const toDataUrl = (blob: Blob) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
const fromDataUrl = (url: string) => fetch(url).then(r => r.blob());
const isDataUrl = (value: unknown) => typeof value === "string" && value.startsWith("data:");
const isSig = (value: unknown) => typeof value === "string" && /^\d+:[0-9a-f]{64}$/.test(value);
async function signature(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return `${file.size}:${[...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, "0")).join("")}`;
}
function trim(entries: Entry[], limit: number) {
const counts = new Map<string, number>();
const kept: Entry[] = [];
const dropped: Entry[] = [];
for (const entry of byRecency(entries)) {
if (entry.pinned) {
kept.push(entry);
continue;
}
const shelf = `${entry.kind}:${entry.group}`;
const nth = (counts.get(shelf) ?? 0) + 1;
counts.set(shelf, nth);
(nth <= limit ? kept : dropped).push(entry);
}
return { kept, dropped };
}
function thumbnail(source: Blob) {
return new Promise<Blob>((resolve, reject) => {
const url = URL.createObjectURL(source);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, THUMB_MAX / Math.max(image.width, image.height));
const canvas = document.createElement("canvas");
canvas.width = Math.round(image.width * scale);
canvas.height = Math.round(image.height * scale);
const context = canvas.getContext("2d");
if (!context) return reject(new Error("could not draw a thumbnail"));
context.drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error("could not draw a thumbnail")), "image/webp", 0.8);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("could not decode that image"));
};
image.src = url;
});
}
export function add(file: File, kind: Kind, group: Group, limit: number, from?: string) {
return queued(async () => {
const sig = await signature(file);
const stored = await readIndex();
const sameShelf = (entry: Entry) => entry.kind === kind && entry.group === group;
const known = stored.find(entry => sameShelf(entry) && entry.sig === sig);
if (known) return known;
const source = from ? stored.find(entry => entry.id === from)?.sig : undefined;
const cropOf = (entry: Entry) => entry.from === source || entry.from === from;
const replaced = source ? stored.filter(entry => sameShelf(entry) && cropOf(entry) && !entry.pinned) : [];
const id = nanoid();
const thumb = await thumbnail(file);
await DataStore.setMany([[fileKey(id), file], [thumbKey(id), thumb]], store);
const entry: Entry = { id, name: file.name, type: file.type, kind, group, sig, added: Date.now(), from: source };
const { kept, dropped } = trim([entry, ...stored.filter(other => !replaced.includes(other))], limit);
await writeIndex(kept);
await forgetBlobs([...dropped, ...replaced].map(other => other.id));
return entry;
});
}
const forgetBlobs = (ids: string[]) => DataStore.delMany(ids.flatMap(id => [fileKey(id), thumbKey(id)]), store);
export function forget(id: string) {
return queued(async () => {
await writeIndex((await readIndex()).filter(entry => entry.id !== id));
await forgetBlobs([id]);
});
}
export async function exportAll() {
const pictures: Array<Entry & { file: string; thumb: string; }> = [];
for (const entry of await readIndex()) {
const [file, thumb] = await Promise.all([getFile(entry.id), getThumb(entry.id)]);
if (!file || !thumb) continue;
pictures.push({ ...entry, file: await toDataUrl(file), thumb: await toDataUrl(thumb) });
}
return JSON.stringify({ format: "BetterImageEditor", version: 2, pictures }, null, 4);
}
export function importAll(json: string, limit: number) {
const parsed = JSON.parse(json);
if (parsed?.format !== "BetterImageEditor" || !Array.isArray(parsed.pictures)) {
throw new Error("that file is not a picture library");
}
const sigById = new Map<string, string>(parsed.pictures.map((picture: Entry) => [picture.id, picture.sig]));
return queued(async () => {
const entries = await readIndex();
const known = new Set(entries.map(entry => `${entry.kind}:${entry.group}:${entry.sig}`));
const imported: Entry[] = [];
try {
for (const { file, thumb, ...picture } of parsed.pictures) {
if (typeof picture.name !== "string" || typeof picture.kind !== "string"
|| typeof picture.sig !== "string" || typeof picture.added !== "number"
|| (picture.group !== "original" && picture.group !== "cropped")
|| !isDataUrl(file) || !isDataUrl(thumb)) continue;
const shelf = `${picture.kind}:${picture.group}:${picture.sig}`;
if (known.has(shelf)) continue;
const decoded = await Promise.all([fromDataUrl(file), fromDataUrl(thumb)]).catch(() => null);
if (!decoded) continue;
const id = nanoid();
known.add(shelf);
await DataStore.setMany([[fileKey(id), decoded[0]], [thumbKey(id), decoded[1]]], store);
imported.push({ ...picture, id, from: isSig(picture.from) ? picture.from : sigById.get(picture.from) });
}
} catch (err) {
await forgetBlobs(imported.map(entry => entry.id));
throw err;
}
const { kept, dropped } = trim([...entries, ...imported], limit);
await writeIndex(kept);
await forgetBlobs(dropped.map(entry => entry.id));
return kept.filter(entry => imported.includes(entry)).length;
});
}
export function forgetCrops() {
return queued(async () => {
const entries = await readIndex();
const framed = entries.filter(entry => entry.crop);
for (const entry of framed) delete entry.crop;
await writeIndex(entries);
return framed.length;
});
}
export const byRecency = (entries: Entry[]) =>
[...entries].sort((a, b) => (b.used ?? b.added) - (a.used ?? a.added));
export function touch(id: string) {
return queued(async () => {
const entries = await readIndex();
const entry = entries.find(entry => entry.id === id);
if (!entry) return;
entry.used = Date.now();
await writeIndex(byRecency(entries));
});
}
export function togglePin(id: string) {
return queued(async () => {
const entries = await readIndex();
const entry = entries.find(entry => entry.id === id);
if (!entry) return;
entry.pinned = !entry.pinned;
await writeIndex(entries);
});
}
const historyKey = (kind: Kind) => `history:${kind}`;
export function recordApplied(kind: Kind, id: string | null) {
return queued(async () => {
const worn = await DataStore.get<(string | null)[]>(historyKey(kind), store) ?? [];
if (worn[0] === id) return;
await DataStore.set(historyKey(kind), [id, ...worn.filter(seen => seen !== id)].slice(0, 5), store);
});
}
export async function previousApplied(kind: Kind) {
const worn = await DataStore.get<(string | null)[]>(historyKey(kind), store) ?? [];
return (await readIndex()).find(entry => entry.id === worn[1]) ?? null;
}
export const currentApplied = (kind: Kind) =>
DataStore.get<(string | null)[]>(historyKey(kind), store).then(worn => worn?.[0] ?? null);
export function saveCrop(id: string, crop: CropState) {
return queued(async () => {
const entries = await readIndex();
const entry = entries.find(entry => entry.id === id);
if (!entry) return;
entry.crop = crop;
await writeIndex(entries);
});
}

View file

@ -0,0 +1,217 @@
.vc-bie-panel {
padding-top: 8px;
}
.vc-bie-tabs {
display: flex;
align-items: center;
gap: 4px;
}
.vc-bie-tab {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: none;
color: var(--interactive-normal);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color .1s ease-out, color .1s ease-out;
}
.vc-bie-tab:focus-visible {
outline: 2px solid var(--focus-primary);
}
.vc-bie-tab:hover:not(.vc-bie-on) {
background: var(--background-modifier-hover);
color: var(--interactive-hover);
}
.vc-bie-tab.vc-bie-on {
background: var(--brand-500, #5865f2);
color: #fff;
}
.vc-bie-action {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: none;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.vc-bie-action:hover {
background: var(--background-modifier-hover);
color: var(--interactive-hover);
}
.vc-bie-action:focus-visible {
outline: 2px solid var(--focus-primary);
}
.vc-bie-tab + .vc-bie-action {
margin-left: auto;
}
.vc-bie-empty {
color: var(--text-muted);
font-size: 12px;
}
.vc-bie-strip {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
padding: 8px 4px 6px;
min-height: 38px;
overflow: auto hidden;
}
.vc-bie-wide .vc-bie-strip {
gap: 8px;
}
.vc-bie-strip::-webkit-scrollbar {
height: 6px;
}
.vc-bie-strip::-webkit-scrollbar-track {
background: transparent;
}
.vc-bie-strip::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
}
.vc-bie-strip:hover::-webkit-scrollbar-thumb {
background: var(--background-modifier-accent);
}
.vc-bie-item {
position: relative;
flex: 0 0 auto;
line-height: 0;
}
.vc-bie-thumb {
width: 44px;
height: 44px;
border-radius: 50%;
padding: 0;
border: 2px solid transparent;
background: var(--background-modifier-hover);
background-size: cover;
background-position: center;
cursor: pointer;
transition: border-color .1s ease-out, transform .1s ease-out;
}
.vc-bie-thumb:focus-visible {
outline: 2px solid var(--focus-primary);
outline-offset: 2px;
}
.vc-bie-thumb.vc-bie-active {
border-color: var(--brand-500, #5865f2);
}
.vc-bie-wide .vc-bie-thumb {
width: 96px;
height: 40px;
border-radius: 6px;
}
.vc-bie-worn .vc-bie-thumb {
box-shadow: 0 0 0 2px var(--status-positive, #23a55a);
}
.vc-bie-item:hover .vc-bie-thumb {
transform: scale(1.06);
}
.vc-bie-remove,
.vc-bie-pin {
position: absolute;
top: -2px;
width: 16px;
height: 16px;
padding: 0;
display: none;
place-items: center;
border: none;
border-radius: 50%;
background: var(--background-tertiary);
color: var(--interactive-normal);
cursor: pointer;
}
.vc-bie-remove {
right: -2px;
}
.vc-bie-pin {
left: -2px;
}
.vc-bie-remove:hover {
color: var(--status-danger);
}
.vc-bie-pin:hover {
color: var(--interactive-active);
}
.vc-bie-remove:focus-visible,
.vc-bie-pin:focus-visible {
outline: 2px solid var(--focus-primary);
}
.vc-bie-pin.vc-bie-on {
color: var(--brand-500, #5865f2);
}
.vc-bie-item:hover .vc-bie-remove,
.vc-bie-item:focus-within .vc-bie-remove,
.vc-bie-item:hover .vc-bie-pin,
.vc-bie-item:focus-within .vc-bie-pin {
display: grid;
}
.vc-bie-pinned::before {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-500, #5865f2);
pointer-events: none;
}
.vc-bie-pinned:hover::before,
.vc-bie-pinned:focus-within::before {
display: none;
}
.vc-bie-buttons {
display: flex;
gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
.vc-bie-tab,
.vc-bie-thumb {
transition: none;
}
.vc-bie-item:hover .vc-bie-thumb {
transform: none;
}
}

View file

@ -697,6 +697,10 @@ export const EquicordDevs = Object.freeze({
name: "nobody",
id: 0n
},
heart_menace: {
name: "heart_menace",
id: 281162701303185408n
},
thororen: {
name: "thororen",
id: 848339671629299742n