BetterImageEditor: Better look of the shelf and couple of other fixes. (#1369)

* Move the shelf next to the editor

Fixing a couple of bugs and issues and moving the shelf with pictures beside the image editor for better usage and much better look from the previous scroller implementation

* misc

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Just_Me 2026-09-23 07:22:07 +02:00 • committed by GitHub
parent 19a57bebfd
commit f5fe2bd9a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 284 additions and 41 deletions

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DeleteIcon } from "@components/Icons";
import { DeleteIcon, SearchIcon } from "@components/Icons";
import { classNameFactory } from "@utils/css";
import { React, useCallback, useEffect, useRef, useState } from "@webpack/common";
@ -53,23 +53,31 @@ export function Shelf({ kind, group, entries, thumbs, activeId, wornId, onGroup,
const shape = SQUARE.has(kind) ? "square" : "wide";
const [hovered, setHovered] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [playing, setPlaying] = useState<Record<string, string>>({});
const played = useRef<string[]>([]);
const live = useRef(true);
useEffect(() => () => played.current.forEach(URL.revokeObjectURL), []);
useEffect(() => () => {
live.current = false;
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;
if (!blob || !live.current) return;
const url = URL.createObjectURL(blob);
played.current.push(url);
setPlaying(current => ({ ...current, [entry.id]: url }));
}, [playing]);
const wanted = filter.trim().toLowerCase();
const shown = wanted ? entries.filter(entry => entry.name.toLowerCase().includes(wanted)) : entries;
return (
<div className={cl("panel", shape)}>
<div className={cl("tabs")} role="tablist">
@ -96,9 +104,36 @@ export function Shelf({ kind, group, entries, thumbs, activeId, wornId, onGroup,
</div>
<div className={cl("bar")}>
<span className={cl("count")}>
{wanted ? `${shown.length} of ${entries.length}` : `${entries.length} kept`}
</span>
{entries.length > 12 && (
<div className={cl("find")}>
<SearchIcon width={12} height={12} aria-hidden />
<input
type="text"
value={filter}
placeholder="Search"
aria-label="Search saved pictures"
onChange={event => setFilter(event.currentTarget.value)}
/>
{filter && (
<button
type="button"
aria-label="Clear the search"
onClick={() => setFilter("")}
>&times;</button>
)}
</div>
)}
</div>
<div className={cl("strip")} role="group" aria-label="Saved pictures">
{entries.map(entry => (
<div
{shown.map(entry => {
const picture = (hovered === entry.id && playing[entry.id]) || thumbs[entry.id];
return <div
key={entry.id}
className={cl("item", { pinned: entry.pinned, worn: entry.id === wornId })}
onMouseEnter={() => play(entry)}
@ -110,7 +145,7 @@ export function Shelf({ kind, group, entries, thumbs, activeId, wornId, onGroup,
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]})` }}
style={picture ? { backgroundImage: `url(${picture})` } : undefined}
onClick={() => onPick(entry)}
onContextMenu={event => {
event.preventDefault();
@ -136,14 +171,16 @@ You are wearing this` : entry.name}
>
<PinIcon width={10} height={10} />
</button>
</div>
))}
</div>;
})}
{!entries.length && (
{!shown.length && (
<span className={cl("empty")}>
{group === "original"
? "Pictures you pick, paste or drop land here"
: "Pictures you crop land here"}
{entries.length
? "Nothing here matches that"
: group === "original"
? "Pictures you pick, paste or drop land here"
: "Pictures you crop land here"}
</span>
)}
</div>

View file

@ -10,7 +10,7 @@ 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 { classes, 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";
@ -62,7 +62,7 @@ const settings = definePluginSettings({
librarySize: {
type: OptionType.SLIDER,
description: "How many pictures to keep on each shelf.",
markers: [6, 12, 24, 48],
markers: [6, 12, 24, 48, 100],
default: 24,
stickToMarkers: true
},
@ -139,7 +139,6 @@ const settings = definePluginSettings({
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;
@ -235,7 +234,7 @@ function useLibrary(kind: Kind) {
.catch(err => logger.error("could not read what you are wearing", err));
}, [kind, version]);
return { group, setGroup, entries, thumbs, worn, bump };
return { group, setGroup, entries, thumbs, worn, version, bump };
}
function useForget(bump: () => void) {
@ -258,6 +257,7 @@ function useForget(bump: () => void) {
}
let editorsOpen = 0;
let gatedPickers = 0;
function usePasteAndDrop(accept: (file: File) => void, active: () => boolean) {
useEffect(() => {
@ -274,7 +274,7 @@ function usePasteAndDrop(accept: (file: File) => void, active: () => boolean) {
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;
if (!active() || !event.dataTransfer?.types.includes("Files")) return;
event.preventDefault();
event.stopPropagation();
};
@ -293,8 +293,8 @@ function usePasteAndDrop(accept: (file: File) => void, active: () => boolean) {
function EditorGate() {
useEffect(() => {
editorAllowed = false;
return () => { editorAllowed = true; };
gatedPickers++;
return () => { gatedPickers--; };
}, []);
return null;
@ -306,7 +306,7 @@ function PickerShelf({ kind, open, complete, maxSize }: {
complete(result: PickResult): void;
maxSize?: number;
}) {
const { group, setGroup, entries, thumbs, worn, bump } = useLibrary(kind);
const { group, setGroup, entries, thumbs, worn, version, bump } = useLibrary(kind);
const onForget = useForget(bump);
const hand = useCallback(async (id: string, file: File, crop: CropState | null) => {
@ -320,11 +320,11 @@ function PickerShelf({ kind, open, complete, maxSize }: {
previousApplied(kind)
.then(setPutBack)
.catch(err => logger.error("could not read what you wore before", err));
}, [kind]);
}, [kind, version]);
const onPick = useCallback(async (entry: Entry) => {
const blob = await getFile(entry.id);
if (!blob) return;
if (!blob) return showToast("That picture is missing", Toasts.Type.FAILURE);
const file = new File([blob], entry.name, { type: blob.type });
await touch(entry.id);
@ -352,6 +352,7 @@ function PickerShelf({ kind, open, complete, maxSize }: {
await hand(entry.id, file, settings.store.rememberCrop ? entry.crop ?? null : null);
} catch (err) {
logger.error("could not take that picture", err);
showToast("Could not use that picture", Toasts.Type.FAILURE);
}
}, [kind, hand, bump, maxSize]);
@ -431,6 +432,7 @@ function EditorShelf({ Original, ownProps }: { Original: React.ComponentType<Edi
await show(entry.id, file, settings.store.rememberCrop ? entry.crop ?? null : null);
} catch (err) {
logger.error("could not take that picture", err);
showToast("Could not use that picture", Toasts.Type.FAILURE);
}
}, [kind, show, bump, setGroup]);
@ -438,7 +440,7 @@ function EditorShelf({ Original, ownProps }: { Original: React.ComponentType<Edi
const onPick = useCallback(async (entry: Entry) => {
const blob = await getFile(entry.id);
if (!blob) return;
if (!blob) return showToast("That picture is missing", Toasts.Type.FAILURE);
await touch(entry.id);
bump();
@ -460,7 +462,10 @@ function EditorShelf({ Original, ownProps }: { Original: React.ComponentType<Edi
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));
const run = () => save().catch(err => {
logger.error("could not keep the cropped copy", err);
showToast("Could not keep the cropped copy", Toasts.Type.FAILURE);
});
if (!settings.store.askBeforeSavingCropped) return void run();
@ -496,7 +501,9 @@ function EditorShelf({ Original, ownProps }: { Original: React.ComponentType<Edi
{...props}
bieShelf={
<ErrorBoundary noop>
<div>
<div className={cl("drawer")} onMouseDown={event => {
if (!(event.target instanceof HTMLInputElement)) event.preventDefault();
}}>
<Shelf
kind={kind}
group={group}
@ -545,9 +552,16 @@ function onProfileDiscarded() {
let wrapped: React.ComponentType<EditorProps> | null = null;
interface Size {
width: number;
height: number;
}
let area: Size | null = null;
export default definePlugin({
name: "BetterImageEditor",
description: "Image editor with shelving for older images and allowing cropping",
description: "Keeps a shelf of your pictures in the avatar and banner editor, and remembers how you cropped each one.",
authors: [EquicordDevs.heart_menace],
settings,
@ -578,6 +592,35 @@ export default definePlugin({
{
match: /(\(0,\i\.jsx\)\(\i\.A,\{id:\i,children:)/,
replace: "bieShelf,$1"
},
{
match: /\("div",\{role:"group","aria-label":/,
replace: '("div",{className:"vc-bie-split",role:"group","aria-label":'
}
]
},
{
find: '"SET_IMAGE_ZOOM_RATIO"',
replacement: {
match: /size:"md",actionBarInput:/,
replace: 'size:"lg",actionBarInput:'
}
},
{
find: '"SET_IMAGE_ZOOM_RATIO"',
group: true,
replacement: [
{
match: /(?<==(\i)\.current\.getBoundingClientRect\(\),)\{width:(\i),height:(\i)\}=(\(0,\i\.\i\)\(\i,\i,\i,\i\))(?=,\i=(\(0,\i\.\i\))\((\i),\2,\3,(\i),(\i)\))/,
replace: "{width:$2,height:$3}=$self.fill($4,(bieW,bieH)=>$5($6,bieW,bieH,$7,$8),$1.current)"
},
{
match: /(\i)=(\(0,\i\.\i\)\(\i,\i,\i,\i\))(?=,\i=(\(0,\i\.\i\))\((\i),\1\.width,\1\.height,(\i),(\i)\))/,
replace: "$1=$self.fill($2,(bieW,bieH)=>$3($4,bieW,bieH,$5,$6))"
},
{
match: /\{width:(\i),height:(\i)\}=(\(0,\i\.\i\)\(\i,\i,\i,\i\))(?=,\i=(\(0,\i\.\i\))\((\i),\1,\2,(\i),(\i)\))/,
replace: "{width:$1,height:$2}=$self.fill($3,(bieW,bieH)=>$4($5,bieW,bieH,$6,$7))"
}
]
},
@ -587,15 +630,36 @@ export default definePlugin({
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])"
}
},
{
find: 'displayName="RecentAvatarsStore"',
replacement: {
match: /size:"md"(,title:\i,actions:\[\],returnRef:\i,children:\(0,\i\.jsxs\)\("div",\{className:)(\i\.\i)/,
replace: 'size:"lg"$1$self.pickerClass($2)'
}
}
],
pickerClass(base: string) {
return classes(base, cl("picker"));
},
fill(size: Size, cropOf: (width: number, height: number) => Size, image?: HTMLImageElement) {
const box = image?.parentElement;
if (box) area = { width: box.clientWidth, height: box.clientHeight };
if (!area) return size;
const crop = cropOf(size.width, size.height);
const scale = Math.min(area.width / crop.width, area.height / crop.height);
return { width: size.width * scale, height: size.height * scale };
},
wrapEditor(Original: React.ComponentType<EditorProps>) {
if (!wrapped) {
const Safe = ErrorBoundary.wrap(EditorShelf, {
fallback: ({ wrappedProps }) => <Original {...wrappedProps.ownProps} />
});
wrapped = (props: EditorProps) => editorAllowed
wrapped = (props: EditorProps) => gatedPickers === 0
? <Safe Original={Original} ownProps={props} />
: <Original {...props} />;
}
@ -608,7 +672,9 @@ export default definePlugin({
return (
<ErrorBoundary noop key="bie-picker">
<PickerShelf kind={kindOf(uploadType)} open={open} complete={complete} maxSize={picker.maxFileSizeBytes} />
<div className={cl("drawer")}>
<PickerShelf kind={kindOf(uploadType)} open={open} complete={complete} maxSize={picker.maxFileSizeBytes} />
</div>
</ErrorBoundary>
);
}

View file

@ -127,7 +127,7 @@ export function add(file: File, kind: Kind, group: Group, limit: number, from?:
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 cropOf = (entry: Entry) => entry.from === source;
const replaced = source ? stored.filter(entry => sameShelf(entry) && cropOf(entry) && !entry.pinned) : [];
const id = nanoid();
const thumb = await thumbnail(file);

View file

@ -4,6 +4,7 @@
.vc-bie-tabs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
@ -62,22 +63,99 @@
font-size: 12px;
}
.vc-bie-strip {
.vc-bie-bar {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 4px 0;
}
.vc-bie-count {
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.vc-bie-find {
display: flex;
align-items: center;
gap: 5px;
margin-left: auto;
padding: 0 6px;
height: 22px;
width: 116px;
border-radius: 11px;
background: var(--background-modifier-hover);
color: var(--text-muted);
transition: width .12s ease-out, background-color .12s ease-out;
}
.vc-bie-find:focus-within {
width: 168px;
background: var(--background-modifier-selected);
color: var(--interactive-active);
}
.vc-bie-find input {
flex: 1 1 auto;
min-width: 0;
height: 100%;
padding: 0;
border: none;
background: none;
color: var(--text-default);
font-size: 12px;
}
.vc-bie-find input:focus {
outline: none;
}
.vc-bie-find input::placeholder {
color: var(--text-muted);
}
.vc-bie-find button {
flex: 0 0 auto;
padding: 0 2px;
border: none;
background: none;
color: var(--text-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.vc-bie-find button:hover {
color: var(--interactive-active);
}
@media (prefers-reduced-motion: reduce) {
.vc-bie-find {
transition: none;
}
}
.vc-bie-strip {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
align-content: start;
gap: 6px;
padding: 8px 4px 6px;
min-height: 38px;
overflow: auto hidden;
max-height: 168px;
overflow: hidden auto;
overscroll-behavior: contain;
}
.vc-bie-wide .vc-bie-strip {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
max-height: 200px;
}
.vc-bie-strip::-webkit-scrollbar {
height: 6px;
width: 8px;
}
.vc-bie-strip::-webkit-scrollbar-track {
@ -85,23 +163,24 @@
}
.vc-bie-strip::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
background: var(--background-modifier-accent);
border-radius: 4px;
}
.vc-bie-strip:hover::-webkit-scrollbar-thumb {
background: var(--background-modifier-accent);
background: var(--background-modifier-selected);
}
.vc-bie-item {
position: relative;
flex: 0 0 auto;
justify-self: center;
line-height: 0;
}
.vc-bie-thumb {
width: 44px;
height: 44px;
display: block;
border-radius: 50%;
padding: 0;
border: 2px solid transparent;
@ -215,3 +294,64 @@
transform: none;
}
}
.vc-bie-split {
position: relative;
padding-right: 260px;
}
.vc-bie-picker {
display: grid;
grid-template-columns: minmax(0, 1fr) 248px;
grid-template-rows: 360px;
column-gap: 12px;
}
.vc-bie-picker > :first-child {
min-height: 0;
}
.vc-bie-picker > :first-child > ul {
flex: 1 1 0;
min-height: 0;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.vc-bie-picker > :first-child > ul > li > :last-child {
box-sizing: border-box;
height: 100%;
aspect-ratio: auto;
}
.vc-bie-split > .vc-bie-drawer,
.vc-bie-picker > .vc-bie-drawer {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 248px;
padding: 8px;
border-radius: var(--radius-md, 8px);
background: var(--background-surface-higher, var(--background-secondary));
overflow: hidden;
}
.vc-bie-split > .vc-bie-drawer {
position: absolute;
inset: 0 0 0 auto;
}
.vc-bie-drawer > .vc-bie-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
padding-top: 0;
}
.vc-bie-split > .vc-bie-drawer .vc-bie-strip,
.vc-bie-picker > .vc-bie-drawer .vc-bie-strip {
flex: 1 1 auto;
min-height: 0;
max-height: none;
}

View file

@ -702,10 +702,6 @@ export const EquicordDevs = Object.freeze({
name: "nobody",
id: 0n
},
heart_menace: {
name: "heart_menace",
id: 281162701303185408n
},
thororen: {
name: "thororen",
id: 848339671629299742n
@ -1447,6 +1443,10 @@ export const EquicordDevs = Object.freeze({
name: "Kurt",
id: 112222963276750848n
},
heart_menace: {
name: "heart_menace",
id: 281162701303185408n
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly