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:
parent
10fc96f725
commit
9cbf3819cc
3 changed files with 313 additions and 165 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1193,6 +1193,10 @@ export const EquicordDevs = Object.freeze({
|
|||
square: {
|
||||
name: "square",
|
||||
id: 219363409097916416n
|
||||
},
|
||||
neoarz: {
|
||||
name: "neoarz",
|
||||
id: 1015372540937502851n
|
||||
}
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue