diff --git a/src/equicordplugins/betterImageEditor/components/Shelf.tsx b/src/equicordplugins/betterImageEditor/components/Shelf.tsx new file mode 100644 index 000000000..c618e5717 --- /dev/null +++ b/src/equicordplugins/betterImageEditor/components/Shelf.tsx @@ -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 = { + 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) => ( + + + +); + +export function Shelf({ kind, group, entries, thumbs, activeId, wornId, onGroup, onPick, onPin, onForget, onPutBack }: { + kind: Kind; + group: Group; + entries: Entry[]; + thumbs: Record; + 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(null); + const [playing, setPlaying] = useState>({}); + const played = useRef([]); + + 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 ( +
+
+ + + + {onPutBack && ( + + )} + +
+ +
+ {entries.map(entry => ( +
play(entry)} + onMouseLeave={() => setHovered(null)} + > + + +
+ ))} + + {!entries.length && ( + + {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 new file mode 100644 index 000000000..eee38cf07 --- /dev/null +++ b/src/equicordplugins/betterImageEditor/index.tsx @@ -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: () => ( + + ) + }, + 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: () => ( +
+ + +
+ ) + }, + clearLibrary: { + type: OptionType.COMPONENT, + description: "Throw away every picture you have saved.", + component: () => ( + + ) + } +}); + +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; + +function offer(kind: Kind, id: string, uris: (string | undefined)[]) { + offered = { kind, id, uris }; +} + +const kindOf = (uploadType?: string): Kind => uploadType || "AVATAR"; + +const EXTENSIONS: Record = { + "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("original"); + const [version, setVersion] = useState(0); + const [entries, setEntries] = useState([]); + const [thumbs, setThumbs] = useState>({}); + const [worn, setWorn] = useState(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 = {}; + 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(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 ( + onPick(putBack) : undefined} + /> + ); +} + +function EditorShelf({ Original, ownProps }: { Original: React.ComponentType; ownProps: EditorProps; }) { + const kind = kindOf(ownProps.uploadType); + + const { group, setGroup, entries, thumbs, worn, bump } = useLibrary(kind); + const onForget = useForget(bump); + + const [picked, setPicked] = useState(null); + const [transform, setTransform] = useState(() => handoff && handoff.file === ownProps.file ? handoff.transform : null); + + const incomingId = useRef(null); + const pickedId = useRef(null); + const pickedName = useRef(null); + const pickedGroup = useRef("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 ( + +
+ +
+ + } + /> + ); +} + +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 | 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) { + if (!wrapped) { + const Safe = ErrorBoundary.wrap(EditorShelf, { + fallback: ({ wrappedProps }) => + }); + wrapped = (props: EditorProps) => editorAllowed + ? + : ; + } + + return wrapped; + }, + + pickerRow(uploadType: string, open: (imageUri: string, file: File) => void, complete: (result: PickResult) => void, picker: PickerProps) { + if (picker.allowRecentAvatarsSelection === false) return ; + + return ( + + + + ); + } +}); diff --git a/src/equicordplugins/betterImageEditor/library.ts b/src/equicordplugins/betterImageEditor/library.ts new file mode 100644 index 000000000..3214e733e --- /dev/null +++ b/src/equicordplugins/betterImageEditor/library.ts @@ -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(INDEX, store).then(entries => entries ?? []); +const writeIndex = (entries: Entry[]) => DataStore.set(INDEX, entries, store); + +let turn: Promise = Promise.resolve(); + +function queued(work: () => Promise) { + const result = turn.then(work); + turn = result.catch(() => { }); + return result; +} + +export const getFile = (id: string) => DataStore.get(fileKey(id), store); +export const getThumb = (id: string) => DataStore.get(thumbKey(id), store); +export const getThumbs = (ids: string[]) => DataStore.getMany(ids.map(thumbKey), store); +export const clear = () => queued(() => DataStore.clear(store)); + +export const toDataUrl = (blob: Blob) => new Promise((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(); + 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((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 = []; + + 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(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); + }); +} diff --git a/src/equicordplugins/betterImageEditor/style.css b/src/equicordplugins/betterImageEditor/style.css new file mode 100644 index 000000000..4744e5a9f --- /dev/null +++ b/src/equicordplugins/betterImageEditor/style.css @@ -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; + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 0ef4c089f..1cee08643 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -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