From f5fe2bd9a430e83d1b1c9a002b1df86903f15d54 Mon Sep 17 00:00:00 2001 From: Just_Me Date: Wed, 23 Sep 2026 07:22:07 +0200 Subject: [PATCH] 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> --- .../betterImageEditor/components/Shelf.tsx | 61 +++++-- .../betterImageEditor/index.tsx | 98 +++++++++-- .../betterImageEditor/library.ts | 2 +- .../betterImageEditor/style.css | 156 +++++++++++++++++- src/utils/constants.ts | 8 +- 5 files changed, 284 insertions(+), 41 deletions(-) diff --git a/src/equicordplugins/betterImageEditor/components/Shelf.tsx b/src/equicordplugins/betterImageEditor/components/Shelf.tsx index c618e5717..de70615fd 100644 --- a/src/equicordplugins/betterImageEditor/components/Shelf.tsx +++ b/src/equicordplugins/betterImageEditor/components/Shelf.tsx @@ -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(null); + const [filter, setFilter] = useState(""); const [playing, setPlaying] = useState>({}); const played = useRef([]); + 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 (
@@ -96,9 +104,36 @@ export function Shelf({ kind, group, entries, thumbs, activeId, wornId, onGroup,
+
+ + {wanted ? `${shown.length} of ${entries.length}` : `${entries.length} kept`} + + {entries.length > 12 && ( +
+ + setFilter(event.currentTarget.value)} + /> + {filter && ( + + )} +
+ )} +
+
- {entries.map(entry => ( -
{ + const picture = (hovered === entry.id && playing[entry.id]) || thumbs[entry.id]; + + return
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} > -
- ))} +
; + })} - {!entries.length && ( + {!shown.length && ( - {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"} )}
diff --git a/src/equicordplugins/betterImageEditor/index.tsx b/src/equicordplugins/betterImageEditor/index.tsx index eee38cf07..ebadef0aa 100644 --- a/src/equicordplugins/betterImageEditor/index.tsx +++ b/src/equicordplugins/betterImageEditor/index.tsx @@ -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(); -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 { 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 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 -
+
{ + if (!(event.target instanceof HTMLInputElement)) event.preventDefault(); + }}> | 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) { if (!wrapped) { const Safe = ErrorBoundary.wrap(EditorShelf, { fallback: ({ wrappedProps }) => }); - wrapped = (props: EditorProps) => editorAllowed + wrapped = (props: EditorProps) => gatedPickers === 0 ? : ; } @@ -608,7 +672,9 @@ export default definePlugin({ return ( - +
+ +
); } diff --git a/src/equicordplugins/betterImageEditor/library.ts b/src/equicordplugins/betterImageEditor/library.ts index 3214e733e..11897a3a4 100644 --- a/src/equicordplugins/betterImageEditor/library.ts +++ b/src/equicordplugins/betterImageEditor/library.ts @@ -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); diff --git a/src/equicordplugins/betterImageEditor/style.css b/src/equicordplugins/betterImageEditor/style.css index 4744e5a9f..dd6ae01da 100644 --- a/src/equicordplugins/betterImageEditor/style.css +++ b/src/equicordplugins/betterImageEditor/style.css @@ -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; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 47d77a035..2baf6ca53 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -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); // iife so #__PURE__ works correctly