feat: quote command rewrite (#501)

* feat: add watermark and remove extra stuff

* fix: watermark toggle in form instead of settings

* feat: save as png/gif toggle

* fix: font fetching codes

* feat: save quote settings (make it persistent)

* feat: add font picker

* chore: update sizing of text

* refactor: quoter utilities into utils.tsx

* chore: remove toggles from settings ui

* fix: add input for watermark in modal

* fix: smoother gradient

* fix: gradient coloring and font size
This commit is contained in:
neo 2025-11-16 15:11:09 -05:00 • committed by GitHub
parent 10fc96f725
commit 9cbf3819cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 313 additions and 165 deletions

View file

@ -8,57 +8,69 @@ import { findGroupChildrenByChildId } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { FormSwitch } from "@components/FormSwitch";
import { Devs } from "@utils/constants";
import { Devs, EquicordDevs } from "@utils/constants";
import { getCurrentChannel } from "@utils/discord";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { Button, Menu, Select, UploadHandler, useEffect, useState } from "@webpack/common";
import { Button, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
import { QuoteIcon } from "./components";
import { canvasToBlob, fetchImageAsBlob, FixUpQuote, wrapText } from "./utils";
enum ImageStyle {
inspirational
}
enum userIDOptions {
displayName,
userName,
userId
}
interface QuoteImageOptions {
avatarUrl: string;
quoteOld: string;
grayScale: boolean;
imageStyle: ImageStyle;
author: {
username: string;
globalName?: string;
id: string;
};
userIdentifier: userIDOptions;
}
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, QuoteFont, resetFontLoading, sizeUpgrade } from "./utils";
const settings = definePluginSettings({
userIdentifier: {
quoteFont: {
type: OptionType.SELECT,
description: "What the author's name should be displayed as",
description: "Font for quote text (author/username always use M PLUS Rounded 1c)",
options: [
{ label: "Display Name", value: userIDOptions.displayName, default: true },
{ label: "Username", value: userIDOptions.userName },
{ label: "User ID", value: userIDOptions.userId }
{ label: "M PLUS Rounded 1c", value: QuoteFont.MPlusRounded, default: true },
{ label: "Open Sans", value: QuoteFont.OpenSans },
{ label: "Momo Signature", value: QuoteFont.MomoSignature },
{ label: "Lora", value: QuoteFont.Lora },
{ label: "Merriweather", value: QuoteFont.Merriweather }
]
},
watermark: {
type: OptionType.STRING,
description: "Custom watermark text (max 32 characters)",
default: "Made with Equicord"
},
grayscale: {
type: OptionType.BOOLEAN,
description: "Enable grayscale by default",
default: true,
hidden: true
},
showWatermark: {
type: OptionType.BOOLEAN,
description: "Show watermark by default",
default: false,
hidden: true
},
saveAsGif: {
type: OptionType.BOOLEAN,
description: "Save as GIF by default",
default: false,
hidden: true
}
});
export default definePlugin({
name: "Quoter",
description: "Adds the ability to create an inspirational quote image from a message",
authors: [Devs.Samwich, Devs.thororen],
authors: [Devs.Samwich, Devs.thororen, EquicordDevs.neoarz],
settings,
async start() {
await ensureFontLoaded();
},
stop() {
const style = document.getElementById("quoter-font-style");
if (style) style.remove();
resetFontLoading();
},
contextMenus: {
"message": (children, { message }) => {
if (!message.content) return;
@ -78,124 +90,51 @@ export default definePlugin({
}
});
function sizeUpgrade(url: string) {
const u = new URL(url);
u.searchParams.set("size", "512");
return u.toString();
}
const preparingSentence: string[] = [];
const lines: string[] = [];
async function createQuoteImage(options: QuoteImageOptions): Promise<Blob> {
const { avatarUrl, quoteOld, grayScale, imageStyle, author, userIdentifier } = options;
const quote = FixUpQuote(quoteOld);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cant get 2d rendering context :(");
let name = "";
switch (userIdentifier) {
case userIDOptions.displayName:
name = author.globalName || author.username;
break;
case userIDOptions.userName:
name = author.username;
break;
case userIDOptions.userId:
name = author.id;
break;
default:
name = "Unknown";
}
switch (imageStyle) {
case ImageStyle.inspirational:
const cardWidth = 1200;
const cardHeight = 600;
canvas.width = cardWidth;
canvas.height = cardHeight;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, cardWidth, cardHeight);
const avatarBlob = await fetchImageAsBlob(avatarUrl);
const fadeBlob = await fetchImageAsBlob("https://raw.githubusercontent.com/Equicord/Equibored/main/icons/quoter/quoter.png");
const avatar = new Image();
const fade = new Image();
await Promise.all([
new Promise<void>(resolve => { avatar.onload = () => resolve(); avatar.src = URL.createObjectURL(avatarBlob); }),
new Promise<void>(resolve => { fade.onload = () => resolve(); fade.src = URL.createObjectURL(fadeBlob); })
]);
ctx.drawImage(avatar, 0, 0, cardHeight, cardHeight);
if (grayScale) {
ctx.globalCompositeOperation = "saturation";
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, cardWidth, cardHeight);
ctx.globalCompositeOperation = "source-over";
}
ctx.drawImage(fade, cardHeight - 400, 0, 400, cardHeight);
ctx.fillStyle = "#fff";
ctx.font = "italic 20px Georgia";
const quoteWidth = cardWidth / 2 - 50;
const quoteX = cardWidth - cardHeight;
const quoteY = cardHeight / 2 - 10;
wrapText(ctx, `"${quote}"`, quoteX, quoteY, quoteWidth, 20, preparingSentence, lines);
const wrappedTextHeight = lines.length * 25;
ctx.font = "bold 16px Georgia";
const authorNameX = cardHeight * 1.5 - ctx.measureText(`- ${name}`).width / 2 - 30;
const authorNameY = quoteY + wrappedTextHeight + 30;
ctx.fillText(`- ${name}`, authorNameX, authorNameY);
preparingSentence.length = 0;
lines.length = 0;
return await canvasToBlob(canvas);
}
}
function generateFileNamePreview(message: string) {
const words = message.split(" ");
return words.length >= 6 ? words.slice(0, 6).join(" ") : words.join(" ");
}
function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
const [gray, setGray] = useState(true);
const [style, setStyle] = useState(ImageStyle.inspirational);
const [gray, setGray] = useState(settings.store.grayscale);
const [showWatermark, setShowWatermark] = useState(settings.store.showWatermark);
const [saveAsGif, setSaveAsGif] = useState(settings.store.saveAsGif);
const [watermarkText, setWatermarkText] = useState(settings.store.watermark);
const [quoteImage, setQuoteImage] = useState<Blob | null>(null);
const { userIdentifier } = settings.store;
const { quoteFont } = settings.store;
const safeContent = message.content ? message.content : "";
useEffect(() => {
settings.store.grayscale = gray;
}, [gray]);
useEffect(() => {
settings.store.showWatermark = showWatermark;
}, [showWatermark]);
useEffect(() => {
settings.store.saveAsGif = saveAsGif;
}, [saveAsGif]);
const generateImage = async () => {
const image = await createQuoteImage({
avatarUrl: sizeUpgrade(message.author.getAvatarURL()),
quoteOld: safeContent,
grayScale: gray,
imageStyle: style,
author: message.author,
userIdentifier
watermark: watermarkText,
showWatermark,
saveAsGif,
quoteFont
});
setQuoteImage(image);
document.getElementById("quoterPreview")?.setAttribute("src", URL.createObjectURL(image));
};
useEffect(() => { generateImage(); }, [gray, style, safeContent]);
useEffect(() => { generateImage(); }, [gray, showWatermark, saveAsGif, safeContent, watermarkText, quoteFont]);
const Export = () => {
if (!quoteImage) return;
const link = document.createElement("a");
const preview = generateFileNamePreview(safeContent);
const extension = saveAsGif ? "gif" : "png";
link.href = URL.createObjectURL(quoteImage);
link.download = `${preview} - ${message.author.username}.png`;
link.download = `${preview} - ${message.author.username}.${extension}`;
link.click();
link.remove();
};
@ -203,7 +142,9 @@ function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
const SendInChat = () => {
if (!quoteImage) return;
const preview = generateFileNamePreview(safeContent);
const file = new File([quoteImage], `${preview} - ${message.author.username}.png`, { type: "image/png" });
const extension = saveAsGif ? "gif" : "png";
const mimeType = saveAsGif ? "image/gif" : "image/png";
const file = new File([quoteImage], `${preview} - ${message.author.username}.${extension}`, { type: mimeType });
// @ts-expect-error typing issue
UploadHandler.promptToUpload([file], getCurrentChannel(), 0);
props.onClose?.();
@ -222,21 +163,36 @@ function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
<br /><br />
<br /><br />
<FormSwitch title="Grayscale" value={gray} onChange={setGray} />
<Select
look={1}
options={Object.keys(ImageStyle)
.filter(key => isNaN(parseInt(key, 10)))
.map(key => ({
label: key.charAt(0).toUpperCase() + key.slice(1),
value: ImageStyle[key as keyof typeof ImageStyle]
}))}
select={v => setStyle(v)}
isSelected={v => v === style}
serialize={v => v}
/>
<FormSwitch title="Save as GIF" value={saveAsGif} onChange={setSaveAsGif} description="Saves/Sends the image as a GIF instead of a PNG" />
{!showWatermark ? (
<FormSwitch
title="Show Watermark"
value={showWatermark}
onChange={setShowWatermark}
/>
) : (
<>
<FormSwitch
title="Show Watermark"
value={showWatermark}
onChange={setShowWatermark}
hideBorder
/>
<div style={{ marginTop: "8px", marginBottom: "20px" }}>
<TextInput
value={watermarkText}
onChange={setWatermarkText}
placeholder="Watermark text (max 32 characters)"
maxLength={32}
/>
</div>
</>
)}
<br />
<Button color={Button.Colors.BRAND} size={Button.Sizes.SMALL} onClick={async () => await Export()} style={{ display: "inline-block", marginRight: "5px" }}>Export</Button>
<Button color={Button.Colors.BRAND} size={Button.Sizes.SMALL} onClick={async () => await SendInChat()} style={{ display: "inline-block" }}>Send</Button>
<div style={{ display: "flex", gap: "5px" }}>
<Button color={Button.Colors.BRAND} size={Button.Sizes.SMALL} onClick={async () => await Export()}>Export</Button>
<Button color={Button.Colors.BRAND} size={Button.Sizes.SMALL} onClick={async () => await SendInChat()}>Send</Button>
</div>
</ModalContent>
<br />
</ModalRoot>

View file

@ -5,6 +5,36 @@
*/
import { UserStore } from "@webpack/common";
import { applyPalette, GIFEncoder, quantize } from "gifenc";
export enum QuoteFont {
MPlusRounded = "M PLUS Rounded 1c",
OpenSans = "Open Sans",
MomoSignature = "Momo Signature",
Lora = "Lora",
Merriweather = "Merriweather"
}
export interface QuoteImageOptions {
avatarUrl: string;
quoteOld: string;
grayScale: boolean;
author: {
username: string;
globalName?: string;
id: string;
};
watermark?: string;
showWatermark?: boolean;
saveAsGif?: boolean;
quoteFont?: QuoteFont;
}
export function sizeUpgrade(url: string) {
const u = new URL(url);
u.searchParams.set("size", "512");
return u.toString();
}
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => {
@ -14,26 +44,6 @@ export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
});
}
export function wrapText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number, preparingSentence: string[], lines: string[]) {
text.split(" ").forEach(word => {
const workSentence = preparingSentence.join(" ") + " " + word;
if (ctx.measureText(workSentence).width > maxWidth) {
lines.push(preparingSentence.join(" "));
preparingSentence = [word];
} else {
preparingSentence.push(word);
}
});
lines.push(preparingSentence.join(" "));
lines.forEach(line => {
const xOffset = (maxWidth - ctx.measureText(line).width) / 2;
y += lineHeight;
ctx.fillText(line, x + xOffset, y);
});
}
export async function fetchImageAsBlob(url: string): Promise<Blob> {
if (!url) throw new Error("Invalid URL: URL is empty or undefined");
try {
@ -69,3 +79,181 @@ export function FixUpQuote(quote: string) {
} catch { }
return quote;
}
export function generateFileNamePreview(message: string) {
const words = message.split(" ");
return words.length >= 6 ? words.slice(0, 6).join(" ") : words.join(" ");
}
let fontLoadingPromise: Promise<void> | null = null;
export async function ensureFontLoaded(): Promise<void> {
if (fontLoadingPromise) return fontLoadingPromise;
fontLoadingPromise = (async () => {
if (!document.getElementById("quoter-font-style")) {
const style = document.createElement("style");
style.id = "quoter-font-style";
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@300&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Momo+Signature&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700&display=swap');
`;
document.head.appendChild(style);
await new Promise(resolve => setTimeout(resolve, 300));
}
})();
return fontLoadingPromise;
}
export function resetFontLoading() {
fontLoadingPromise = null;
}
async function canvasToGif(canvas: HTMLCanvasElement): Promise<Blob> {
const gif = GIFEncoder();
const ctx = canvas.getContext("2d")!;
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
const palette = quantize(data, 256);
const index = applyPalette(data, palette);
gif.writeFrame(index, canvas.width, canvas.height, {
transparent: false,
palette,
});
gif.finish();
return new Blob([new Uint8Array(gif.bytesView())], { type: "image/gif" });
}
export async function createQuoteImage(options: QuoteImageOptions): Promise<Blob> {
const { avatarUrl, quoteOld, grayScale, author, watermark, showWatermark, saveAsGif, quoteFont } = options;
await ensureFontLoaded();
const quote = FixUpQuote(quoteOld);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cant get 2d rendering context :(");
const name = author.globalName || author.username;
const selectedQuoteFont = quoteFont || QuoteFont.MPlusRounded;
const cardWidth = 1200;
const cardHeight = 600;
canvas.width = cardWidth;
canvas.height = cardHeight;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, cardWidth, cardHeight);
const avatarBlob = await fetchImageAsBlob(avatarUrl);
const avatar = new Image();
await new Promise<void>(resolve => {
avatar.onload = () => resolve();
avatar.src = URL.createObjectURL(avatarBlob);
});
ctx.drawImage(avatar, 0, 0, cardHeight, cardHeight);
if (grayScale) {
ctx.globalCompositeOperation = "saturation";
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, cardWidth, cardHeight);
ctx.globalCompositeOperation = "source-over";
}
const gradient = ctx.createLinearGradient(cardHeight - 400, 0, cardHeight, 0);
gradient.addColorStop(0, "rgba(0, 0, 0, 0)");
gradient.addColorStop(1, "rgba(0, 0, 0, 1)");
ctx.fillStyle = gradient;
ctx.fillRect(cardHeight - 400, 0, 400, cardHeight);
const quoteWidth = cardWidth / 2 - 80;
const quoteX = cardWidth - cardHeight + 40;
const maxContentHeight = cardHeight * 0.8;
const calculateLines = (text: string, fontSize: number): string[] => {
ctx.font = `300 ${fontSize}px '${selectedQuoteFont}', sans-serif`;
const words = text.split(" ");
const lines: string[] = [];
let currentLine: string[] = [];
words.forEach(word => {
const testLine = [...currentLine, word].join(" ");
if (ctx.measureText(testLine).width > quoteWidth && currentLine.length > 0) {
lines.push(currentLine.join(" "));
currentLine = [word];
} else {
currentLine.push(word);
}
});
if (currentLine.length > 0) {
lines.push(currentLine.join(" "));
}
return lines;
};
let fontSize = 42;
let lineHeight = fontSize * 1.25;
let lines: string[] = [];
let authorFontSize = 30;
let usernameFontSize = 22;
let totalHeight = 0;
while (fontSize >= 18) {
lines = calculateLines(quote, fontSize);
lineHeight = fontSize * 1.25;
authorFontSize = Math.max(22, fontSize * 0.60);
usernameFontSize = Math.max(18, fontSize * 0.45);
const spacing = 60;
const usernameSpacing = 10;
totalHeight = (lines.length * lineHeight) + spacing + authorFontSize + usernameSpacing + usernameFontSize;
if (totalHeight <= maxContentHeight) {
break;
}
fontSize -= 2;
}
ctx.fillStyle = "#fff";
ctx.font = `300 ${fontSize}px '${selectedQuoteFont}', sans-serif`;
let quoteY = (cardHeight - totalHeight) / 2;
lines.forEach(line => {
const xOffset = (quoteWidth - ctx.measureText(line).width) / 2;
quoteY += lineHeight;
ctx.fillText(line, quoteX + xOffset, quoteY);
});
ctx.font = `italic 300 ${authorFontSize}px 'M PLUS Rounded 1c', sans-serif`;
const authorText = `- ${name}`;
const authorNameX = quoteX + (quoteWidth - ctx.measureText(authorText).width) / 2;
const authorNameY = quoteY + 50;
ctx.fillText(authorText, authorNameX, authorNameY);
const username = `@${author.username}`;
ctx.font = `300 ${usernameFontSize}px 'M PLUS Rounded 1c', sans-serif`;
ctx.fillStyle = "#888";
const usernameX = quoteX + (quoteWidth - ctx.measureText(username).width) / 2;
const usernameY = authorNameY + 10 + usernameFontSize;
ctx.fillText(username, usernameX, usernameY);
if (showWatermark && watermark) {
ctx.fillStyle = "#888";
ctx.font = "300 18px 'M PLUS Rounded 1c', sans-serif";
const watermarkText = watermark.slice(0, 32);
const watermarkX = cardWidth - ctx.measureText(watermarkText).width - 20;
const watermarkY = cardHeight - 20;
ctx.fillText(watermarkText, watermarkX, watermarkY);
}
return saveAsGif ? await canvasToGif(canvas) : await canvasToBlob(canvas);
}

View file

@ -1193,6 +1193,10 @@ export const EquicordDevs = Object.freeze({
square: {
name: "square",
id: 219363409097916416n
},
neoarz: {
name: "neoarz",
id: 1015372540937502851n
}
} satisfies Record<string, Dev>);