diff --git a/README.md b/README.md index 521fd1e3..08ba6179 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,6 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch - GlobalBadges by HypedDomi & Hosted by Wolfie - GoogleThat by Samwich - HideChatButtons by iamme -- HideMessage by Hanzy - HideServers by bepvte - HolyNotes by Wolfie - HomeTyping by Samwich @@ -115,6 +114,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch - QuestCompleter by Amia - QuestionMarkReplacement by nyx - Quoter by Samwich +- Remix by MrDiamond - RemixMe by kvba - RepeatMessage by Tolgchu - ReplyPingControl by ant0n & MrDiamond diff --git a/src/equicordplugins/gifCollections/index.tsx b/src/equicordplugins/gifCollections/index.tsx index 271261cd..33fff6ac 100644 --- a/src/equicordplugins/gifCollections/index.tsx +++ b/src/equicordplugins/gifCollections/index.tsx @@ -6,7 +6,7 @@ import "./style.css"; -import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; import { DataStore } from "@api/index"; import { definePluginSettings } from "@api/Settings"; import { Flex } from "@components/Flex"; @@ -317,15 +317,14 @@ export default definePlugin({ }, ], settings, + contextMenus: { + "message": addCollectionContextMenuPatch + }, start() { refreshCacheCollection(); - addContextMenuPatch("message", addCollectionContextMenuPatch); GIF_COLLECTION_PREFIX = settings.store.collectionPrefix; GIF_ITEM_PREFIX = settings.store.itemPrefix; }, - stop() { - removeContextMenuPatch("message", addCollectionContextMenuPatch); - }, get collections() { refreshCacheCollection(); return this.sortedCollections(); diff --git a/src/equicordplugins/hideMessage/EyeIcon.tsx b/src/equicordplugins/hideMessage/EyeIcon.tsx deleted file mode 100644 index eae2323b..00000000 --- a/src/equicordplugins/hideMessage/EyeIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -export function EyeIcon({ height = 24, width = 24, className }: { height?: number; width?: number; className?: string; }) { - return ( - - - - - ); -} diff --git a/src/equicordplugins/hideMessage/HideIcon.tsx b/src/equicordplugins/hideMessage/HideIcon.tsx deleted file mode 100644 index fb80d194..00000000 --- a/src/equicordplugins/hideMessage/HideIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -export function HideIcon({ height = 24, width = 24, className }: { height?: number; width?: number; className?: string; }) { - return ( - - - - - ); -} diff --git a/src/equicordplugins/hideMessage/HideMessageAccessory.tsx b/src/equicordplugins/hideMessage/HideMessageAccessory.tsx deleted file mode 100644 index 0048d05e..00000000 --- a/src/equicordplugins/hideMessage/HideMessageAccessory.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { cl, revealMessage } from "./"; -import { HideIcon } from "./HideIcon"; - -export const HideMessageAccessory = ({ id }: { id: string; }) => { - return ( - - - This message is hidden •{" "} - revealMessage(id)} className={cl("reveal")}> - Reveal - - - ); -}; diff --git a/src/equicordplugins/hideMessage/index.tsx b/src/equicordplugins/hideMessage/index.tsx deleted file mode 100644 index 21265f17..00000000 --- a/src/equicordplugins/hideMessage/index.tsx +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import "./styles.css"; - -import { NavContextMenuPatchCallback } from "@api/ContextMenu"; -import { get, set } from "@api/DataStore"; -import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories"; -import { definePluginSettings } from "@api/Settings"; -import { classNameFactory } from "@api/Styles"; -import { EquicordDevs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; -import { Menu } from "@webpack/common"; - -import { EyeIcon } from "./EyeIcon"; -import { HideIcon } from "./HideIcon"; -import { HideMessageAccessory } from "./HideMessageAccessory"; - -let style: HTMLStyleElement; - -const KEY = "HideMessage_hiddenMessages"; - -let hiddenMessages = new Map(); - -const patchMessageContextMenu: NavContextMenuPatchCallback = (children, { message }) => { - const { deleted, id, channel_id } = message; - if (deleted || message.state !== "SENT") return; - - const isHidden = hiddenMessages?.has(id) || false; - if (isHidden) { - return children.push( - revealMessage(id)} - /> - ); - } - - children.push( { - hiddenMessages.set(id, { id, channel_id }); - if (settings.store.saveHiddenMessages) set(KEY, hiddenMessages); - - buildCss(); - }} - />); -}; - -const buildCss = () => { - const elements = [...hiddenMessages.values()].map(m => `#chat-messages-${m.channel_id}-${m.id}`).join(","); - - style.textContent = settings.store.showNotice ? ` - :is(${elements}):not(.messagelogger-deleted) > div { - position: relative; - background: var(--brand-experiment-05a); - } - :is(${elements}):not(.messagelogger-deleted) > div:hover { - background: var(--brand-experiment-10a); - } - :is(${elements}):not(.messagelogger-deleted) > div:before { - background: var(--brand-500); - content: ""; - position: absolute; - display: block; - top: 0; - left: 0; - bottom: 0; - pointer-events: none; - width: 2px; - } - :is(${elements}) [id^='message-accessories'] > *:not(.vc-hide-message-accessory), - :is(${elements}) [id^='message-content']:not([class^='repliedTextContent']) > * { - display: none !important; - } - :is(${elements}) [id^='message-content']:empty { - display: block !important; - } - :is(${elements}) [class^='contents'] [id^='message-content']:after { - content: "Hidden content"; - } - ` : ` - :is(${elements}) { - display: none !important; - } - `; -}; - -export const revealMessage = (id: string) => { - const isHidden = hiddenMessages?.has(id) || false; - if (isHidden) { - hiddenMessages.delete(id); - buildCss(); - - if (settings.store.saveHiddenMessages) set(KEY, hiddenMessages); - } -}; - -export const cl = classNameFactory("vc-hide-message-"); - -export const settings = definePluginSettings({ - showNotice: { - type: OptionType.BOOLEAN, - description: "Shows a notice when a message is hidden", - default: true, - onChange: buildCss - }, - saveHiddenMessages: { - type: OptionType.BOOLEAN, - description: "Persist restarts", - default: false, - onChange: async (value: boolean) => { - if (value) set(KEY, hiddenMessages); - else (hiddenMessages = await get(KEY) || hiddenMessages); - } - }, -}); - -export default definePlugin({ - name: "HideMessage", - description: "Adds a context menu option to hide messages", - authors: [EquicordDevs.Hanzy], - dependencies: ["MessageAccessoriesAPI", "MessagePopoverAPI"], - settings, - - contextMenus: { - "message": patchMessageContextMenu - }, - - async start() { - style = document.createElement("style"); - style.id = "VencordHideMessage"; - document.head.appendChild(style); - - if (settings.store.saveHiddenMessages) { - hiddenMessages = await get(KEY) || hiddenMessages; - buildCss(); - } - - addMessageAccessory("vc-hide-message", ({ message }) => { - const isHidden = hiddenMessages?.has(message.id) || false; - if (isHidden && settings.store.showNotice) return ; - return null; - }); - }, - - async stop() { - for (const id of hiddenMessages.keys()) revealMessage(id); - - removeMessageAccessory("vc-hide-message"); - - style.remove(); - hiddenMessages.clear(); - }, -}); diff --git a/src/equicordplugins/hideMessage/styles.css b/src/equicordplugins/hideMessage/styles.css deleted file mode 100644 index 7d59b8fb..00000000 --- a/src/equicordplugins/hideMessage/styles.css +++ /dev/null @@ -1,21 +0,0 @@ -.vc-hide-message-accessory { - margin-top: 4px; - font-size: 12px; - font-weight: 400; - color: var(--text-muted); -} - -.vc-hide-message-accessory svg { - margin-right: 4px; - vertical-align: text-bottom; -} - -.vc-hide-message-reveal { - all: unset; - cursor: pointer; - color: var(--text-link); -} - -.vc-hide-message-reveal:is(:hover, :focus) { - text-decoration: underline; -} diff --git a/src/equicordplugins/remix/RemixModal.tsx b/src/equicordplugins/remix/RemixModal.tsx new file mode 100644 index 00000000..d6228eaf --- /dev/null +++ b/src/equicordplugins/remix/RemixModal.tsx @@ -0,0 +1,54 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal"; +import { Button, Text } from "@webpack/common"; + +import { sendRemix } from "."; +import { brushCanvas, canvas, cropCanvas, ctx, exportImg, shapeCanvas } from "./editor/components/Canvas"; +import { Editor } from "./editor/Editor"; +import { resetBounds } from "./editor/tools/crop"; +import { SendIcon } from "./icons/SendIcon"; + +type Props = { + modalProps: ModalProps; + close: () => void; + url?: string; +}; + +function reset() { + resetBounds(); + + if (!ctx || !canvas) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + brushCanvas.clearRect(0, 0, canvas.width, canvas.height); + shapeCanvas.clearRect(0, 0, canvas.width, canvas.height); + cropCanvas.clearRect(0, 0, canvas.width, canvas.height); +} + +async function closeModal(closeFunc: () => void, save?: boolean) { + if (save) sendRemix(await exportImg()); + reset(); + closeFunc(); +} + +export default function RemixModal({ modalProps, close, url }: Props) { + return ( + + + Remix + closeModal(close)} /> + + + + + + closeModal(close, true)} className="vc-remix-send"> Send + closeModal(close)} color={Button.Colors.RED}>Close + + + ); +} diff --git a/src/equicordplugins/remix/editor/Editor.tsx b/src/equicordplugins/remix/editor/Editor.tsx new file mode 100644 index 00000000..a96fd9c1 --- /dev/null +++ b/src/equicordplugins/remix/editor/Editor.tsx @@ -0,0 +1,44 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findComponentByCodeLazy } from "@webpack"; +import { useEffect, useState } from "@webpack/common"; + +import { Canvas } from "./components/Canvas"; +import { Toolbar } from "./components/Toolbar"; +import { imageToBlob, urlToImage } from "./utils/canvas"; + +const FileUpload = findComponentByCodeLazy("fileUploadInput,"); + +export const Editor = (props: { url?: string; }) => { + const [file, setFile] = useState(undefined); + + useEffect(() => { + if (!props.url) return; + + urlToImage(props.url).then(img => { + imageToBlob(img).then(blob => { + setFile(new File([blob], "remix.png")); + }); + }); + }, []); + + return ( + + {!file && setFile(file)} + />} + {file && (<> + + + >)} + + ); +}; diff --git a/src/equicordplugins/remix/editor/components/Canvas.tsx b/src/equicordplugins/remix/editor/components/Canvas.tsx new file mode 100644 index 00000000..39e1bb42 --- /dev/null +++ b/src/equicordplugins/remix/editor/components/Canvas.tsx @@ -0,0 +1,82 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { useEffect, useRef } from "@webpack/common"; + +import { initInput } from "../input"; +import { bounds } from "../tools/crop"; +import { heightFromBounds, widthFromBounds } from "../utils/canvas"; + +export let canvas: HTMLCanvasElement | null = null; +export let ctx: CanvasRenderingContext2D | null = null; + +export const brushCanvas = document.createElement("canvas")!.getContext("2d")!; +export const shapeCanvas = document.createElement("canvas")!.getContext("2d")!; +export const cropCanvas = document.createElement("canvas")!.getContext("2d")!; + +export let image: HTMLImageElement; + +export function exportImg(): Promise { + return new Promise(resolve => { + if (!canvas || !ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0); + ctx.drawImage(brushCanvas.canvas, 0, 0); + + if (bounds.right === -1) bounds.right = canvas.width; + if (bounds.bottom === -1) bounds.bottom = canvas.height; + + const renderCanvas = document.createElement("canvas"); + renderCanvas.width = widthFromBounds(bounds); + renderCanvas.height = heightFromBounds(bounds); + + const renderCtx = renderCanvas.getContext("2d")!; + renderCtx.drawImage(canvas, -bounds.left, -bounds.top); + renderCanvas.toBlob(blob => resolve(blob!)); + + render(); + }); +} + +export const Canvas = ({ file }: { file: File; }) => { + const canvasRef = useRef(null); + + useEffect(() => { + image = new Image(); + image.src = URL.createObjectURL(file); + image.onload = () => { + canvas = canvasRef.current; + + if (!canvas) return; + + canvas.width = image.width; + canvas.height = image.height; + brushCanvas.canvas.width = image.width; + brushCanvas.canvas.height = image.height; + shapeCanvas.canvas.width = image.width; + shapeCanvas.canvas.height = image.height; + cropCanvas.canvas.width = image.width; + cropCanvas.canvas.height = image.height; + + ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + + initInput(); + }; + }); + + return (); +}; + +export function render() { + if (!ctx || !canvas) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0); + ctx.drawImage(brushCanvas.canvas, 0, 0); + ctx.drawImage(shapeCanvas.canvas, 0, 0); + ctx.drawImage(cropCanvas.canvas, 0, 0); +} diff --git a/src/equicordplugins/remix/editor/components/SettingColorComponent.tsx b/src/equicordplugins/remix/editor/components/SettingColorComponent.tsx new file mode 100644 index 00000000..fbda3e41 --- /dev/null +++ b/src/equicordplugins/remix/editor/components/SettingColorComponent.tsx @@ -0,0 +1,52 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// brutally ripped out of usercss +// (remove when usercss is merged) + +import "./colorStyles.css"; + +import { classNameFactory } from "@api/Styles"; +import { findComponentByCodeLazy } from "@webpack"; +import { Forms } from "@webpack/common"; + +interface ColorPickerProps { + color: number | null; + showEyeDropper?: boolean; + onChange(value: number | null): void; +} + +const ColorPicker = findComponentByCodeLazy(".BACKGROUND_PRIMARY).hex"); + +const cl = classNameFactory("vc-remix-settings-color-"); + +interface Props { + name: string; + color: number; + onChange(value: string): void; +} + +function hexToColorString(color: number): string { + return `#${color.toString(16).padStart(6, "0")}`; +} + +export function SettingColorComponent({ name, onChange, color }: Props) { + function handleChange(newColor: number) { + onChange(hexToColorString(newColor)); + } + + return ( + + + + + + ); +} diff --git a/src/equicordplugins/remix/editor/components/Toolbar.tsx b/src/equicordplugins/remix/editor/components/Toolbar.tsx new file mode 100644 index 00000000..dac61b82 --- /dev/null +++ b/src/equicordplugins/remix/editor/components/Toolbar.tsx @@ -0,0 +1,141 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Switch } from "@components/Switch"; +import { Button, Forms, Select, Slider, useEffect, useState } from "@webpack/common"; + +import { BrushTool } from "../tools/brush"; +import { CropTool, resetBounds } from "../tools/crop"; +import { EraseTool } from "../tools/eraser"; +import { currentShape, setShape, setShapeFill, Shape, ShapeTool } from "../tools/shape"; +import { brushCanvas, canvas, cropCanvas, render, shapeCanvas } from "./Canvas"; +import { SettingColorComponent } from "./SettingColorComponent"; + +export type Tool = "none" | "brush" | "erase" | "crop" | "shape"; + +export type ToolDefinition = { + selected: () => void; + unselected: () => void; + [key: string]: any; +}; + +export const tools: Record = { + none: undefined, + brush: BrushTool, + erase: EraseTool, + crop: CropTool, + shape: ShapeTool, +}; + +export let currentTool: Tool = "none"; +export let currentColor = "#ff0000"; +export let currentSize = 20; +export let currentFill = false; + +function colorStringToHex(color: string): number { + return parseInt(color.replace("#", ""), 16); +} + +export const Toolbar = () => { + const [tool, setTool] = useState(currentTool); + const [color, setColor] = useState(currentColor); + const [size, setSize] = useState(currentSize); + const [fill, setFill] = useState(currentFill); + + function changeTool(newTool: Tool) { + const oldTool = tool; + + setTool(newTool); + onChangeTool(oldTool, newTool); + } + + function onChangeTool(old: Tool, newTool: Tool) { + tools[old]?.unselected(); + tools[newTool]?.selected(); + } + + useEffect(() => { + currentTool = tool; + currentColor = color; + currentSize = size; + currentFill = fill; + + brushCanvas.fillStyle = color; + shapeCanvas.fillStyle = color; + + brushCanvas.strokeStyle = color; + shapeCanvas.strokeStyle = color; + + brushCanvas.lineWidth = size; + shapeCanvas.lineWidth = size; + + brushCanvas.lineCap = "round"; + brushCanvas.lineJoin = "round"; + + setShapeFill(currentFill); + }, [tool, color, size, fill]); + + function clear() { + if (!canvas) return; + + brushCanvas.clearRect(0, 0, canvas.width, canvas.height); + shapeCanvas.clearRect(0, 0, canvas.width, canvas.height); + resetBounds(); + if (tool !== "crop") cropCanvas.clearRect(0, 0, canvas.width, canvas.height); + render(); + } + + return ( + + + changeTool("brush")}>Brush + changeTool("erase")}>Erase + changeTool("crop")}>Crop + changeTool("shape")}>Shape + + + + {(tool === "brush" || tool === "shape") && + + } + + {(tool === "brush" || tool === "erase" || tool === "shape") && + + } + + {(tool === "crop") && Reset} + + {(tool === "shape") && (<> + v === currentShape} + serialize={v => String(v)} + placeholder="Shape" + options={ + ["Rectangle", "Ellipse", "Line", "Arrow"].map(v => ({ + label: v, + value: v.toLowerCase() as Shape, + })) + } + /> + + Fill + >)} + + + + Clear + + + ); +}; diff --git a/src/equicordplugins/remix/editor/components/colorStyles.css b/src/equicordplugins/remix/editor/components/colorStyles.css new file mode 100644 index 00000000..4f92002e --- /dev/null +++ b/src/equicordplugins/remix/editor/components/colorStyles.css @@ -0,0 +1,19 @@ +.vc-remix-settings-color-swatch-row { + display: flex; + flex-direction: row; + width: 100%; + align-items: center; +} + +.vc-remix-settings-color-swatch-row > span { + display: block; + flex: 1; + overflow: hidden; + margin-top: 0; + margin-bottom: 0; + color: var(--header-primary); + line-height: 24px; + font-size: 16px; + font-weight: 500; + word-wrap: break-word; +} diff --git a/src/equicordplugins/remix/editor/input.ts b/src/equicordplugins/remix/editor/input.ts new file mode 100644 index 00000000..d47a3846 --- /dev/null +++ b/src/equicordplugins/remix/editor/input.ts @@ -0,0 +1,57 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { canvas } from "./components/Canvas"; +import { EventEmitter } from "./utils/eventEmitter"; + +export const Mouse = { + x: 0, + y: 0, + down: false, + dx: 0, + dy: 0, + prevX: 0, + prevY: 0, + event: new EventEmitter() +}; + +export function initInput() { + if (!canvas) return; + canvas.addEventListener("mousemove", e => { + Mouse.prevX = Mouse.x; + Mouse.prevY = Mouse.y; + + const rect = canvas!.getBoundingClientRect(); + const scaleX = canvas!.width / rect.width; + const scaleY = canvas!.height / rect.height; + + Mouse.x = (e.clientX - rect.left) * scaleX; + Mouse.y = (e.clientY - rect.top) * scaleY; + + Mouse.dx = Mouse.x - Mouse.prevX; + Mouse.dy = Mouse.y - Mouse.prevY; + + Mouse.event.emit("move", e); + }); + + canvas.addEventListener("mousedown", e => { + Mouse.down = true; + + Mouse.event.emit("down", e); + }); + + canvas.addEventListener("mouseup", e => { + Mouse.down = false; + + Mouse.event.emit("up", e); + }); + + canvas.addEventListener("mouseleave", e => { + Mouse.down = false; + + Mouse.event.emit("up", e); + }); +} diff --git a/src/equicordplugins/remix/editor/tools/brush.ts b/src/equicordplugins/remix/editor/tools/brush.ts new file mode 100644 index 00000000..7c9e49b4 --- /dev/null +++ b/src/equicordplugins/remix/editor/tools/brush.ts @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { brushCanvas, ctx, render } from "../components/Canvas"; +import { currentSize, ToolDefinition } from "../components/Toolbar"; +import { Mouse } from "../input"; +import { line } from "../utils/canvas"; + +export const BrushTool: ToolDefinition = { + onMouseMove() { + if (!Mouse.down || !ctx) return; + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + brushCanvas.lineWidth = currentSize; + + line(Mouse.prevX, Mouse.prevY, Mouse.x, Mouse.y); + + render(); + }, + selected() { + Mouse.event.on("move", this.onMouseMove); + }, + unselected() { + Mouse.event.off("move", this.onMouseMove); + }, +}; diff --git a/src/equicordplugins/remix/editor/tools/crop.ts b/src/equicordplugins/remix/editor/tools/crop.ts new file mode 100644 index 00000000..a20bb7e2 --- /dev/null +++ b/src/equicordplugins/remix/editor/tools/crop.ts @@ -0,0 +1,151 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { canvas, cropCanvas, render } from "../components/Canvas"; +import { ToolDefinition } from "../components/Toolbar"; +import { Mouse } from "../input"; +import { dist, fillCircle } from "../utils/canvas"; + +export const bounds = { + top: 0, + left: 0, + right: -1, + bottom: -1, +}; + +export function resetBounds() { + if (!canvas) return; + + bounds.top = 0; + bounds.left = 0; + bounds.right = canvas.width; + bounds.bottom = canvas.height; + + CropTool.update(); +} + +export const CropTool: ToolDefinition = { + dragging: "", + + onMouseMove() { + if (!canvas) return; + + if (this.dragging !== "") { + if (this.dragging.includes("left")) bounds.left = Mouse.x; + if (this.dragging.includes("right")) bounds.right = Mouse.x; + if (this.dragging.includes("top")) bounds.top = Mouse.y; + if (this.dragging.includes("bottom")) bounds.bottom = Mouse.y; + + this.update(); + return; + } + + if (dist(Mouse.x, Mouse.y, bounds.left, bounds.top) < 30) { + if (Mouse.down) { + bounds.left = Mouse.x; + bounds.top = Mouse.y; + this.dragging = "left top"; + } else { + canvas.style.cursor = "nwse-resize"; + } + } + else if (dist(Mouse.x, Mouse.y, bounds.right, bounds.top) < 30) { + if (Mouse.down) { + bounds.right = Mouse.x; + bounds.top = Mouse.y; + this.dragging = "right top"; + } else { + canvas.style.cursor = "nesw-resize"; + } + } + else if (dist(Mouse.x, Mouse.y, bounds.left, bounds.bottom) < 30) { + if (Mouse.down) { + bounds.left = Mouse.x; + bounds.bottom = Mouse.y; + this.dragging = "left bottom"; + } else { + canvas.style.cursor = "nesw-resize"; + } + } + else if (dist(Mouse.x, Mouse.y, bounds.right, bounds.bottom) < 30) { + if (Mouse.down) { + bounds.right = Mouse.x; + bounds.bottom = Mouse.y; + this.dragging = "right bottom"; + } else { + canvas.style.cursor = "nwse-resize"; + } + } else { + canvas.style.cursor = "default"; + } + + if (this.dragging !== "") this.update(); + }, + + onMouseUp() { + this.dragging = ""; + + if (bounds.left > bounds.right) [bounds.left, bounds.right] = [bounds.right, bounds.left]; + if (bounds.top > bounds.bottom) [bounds.top, bounds.bottom] = [bounds.bottom, bounds.top]; + }, + + update() { + if (!canvas) return; + + cropCanvas.clearRect(0, 0, canvas.width, canvas.height); + cropCanvas.fillStyle = "rgba(0, 0, 0, 0.75)"; + cropCanvas.fillRect(0, 0, canvas.width, canvas.height); + + cropCanvas.fillStyle = "rgba(0, 0, 0, 0.25)"; + cropCanvas.clearRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top); + cropCanvas.fillRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top); + + cropCanvas.fillStyle = "white"; + cropCanvas.strokeStyle = "white"; + cropCanvas.lineWidth = 3; + + cropCanvas.strokeRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top); + + fillCircle(bounds.left, bounds.top, 10, cropCanvas); + fillCircle(bounds.right, bounds.top, 10, cropCanvas); + fillCircle(bounds.left, bounds.bottom, 10, cropCanvas); + fillCircle(bounds.right, bounds.bottom, 10, cropCanvas); + + render(); + }, + + onMouseMoveCallback: undefined, + onMouseUpCallback: undefined, + + selected() { + if (!canvas) return; + + if (bounds.right === -1) bounds.right = canvas.width; + if (bounds.bottom === -1) bounds.bottom = canvas.height; + + this.update(); + + this.onMouseMoveCallback = this.onMouseMove.bind(this); + this.onMouseUpCallback = this.onMouseUp.bind(this); + + Mouse.event.on("move", this.onMouseMoveCallback); + Mouse.event.on("up", this.onMouseUpCallback); + }, + unselected() { + if (!canvas) return; + + cropCanvas.clearRect(0, 0, canvas.width, canvas.height); + + cropCanvas.fillStyle = "rgba(0, 0, 0, 0.75)"; + cropCanvas.fillRect(0, 0, canvas.width, canvas.height); + cropCanvas.clearRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top); + + render(); + + Mouse.event.off("move", this.onMouseMoveCallback); + Mouse.event.off("up", this.onMouseUpCallback); + }, +}; diff --git a/src/equicordplugins/remix/editor/tools/eraser.ts b/src/equicordplugins/remix/editor/tools/eraser.ts new file mode 100644 index 00000000..fcae3151 --- /dev/null +++ b/src/equicordplugins/remix/editor/tools/eraser.ts @@ -0,0 +1,36 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { brushCanvas, render } from "../components/Canvas"; +import { currentSize, ToolDefinition } from "../components/Toolbar"; +import { Mouse } from "../input"; + +export const EraseTool: ToolDefinition = { + onMouseMove() { + if (!Mouse.down) return; + + brushCanvas.lineCap = "round"; + brushCanvas.lineJoin = "round"; + brushCanvas.lineWidth = currentSize; + + brushCanvas.globalCompositeOperation = "destination-out"; + + brushCanvas.beginPath(); + brushCanvas.moveTo(Mouse.prevX, Mouse.prevY); + brushCanvas.lineTo(Mouse.x, Mouse.y); + brushCanvas.stroke(); + + brushCanvas.globalCompositeOperation = "source-over"; + + render(); + }, + selected() { + Mouse.event.on("move", this.onMouseMove); + }, + unselected() { + Mouse.event.off("move", this.onMouseMove); + }, +}; diff --git a/src/equicordplugins/remix/editor/tools/shape.ts b/src/equicordplugins/remix/editor/tools/shape.ts new file mode 100644 index 00000000..9a15c3ab --- /dev/null +++ b/src/equicordplugins/remix/editor/tools/shape.ts @@ -0,0 +1,109 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { brushCanvas, render, shapeCanvas } from "../components/Canvas"; +import { ToolDefinition } from "../components/Toolbar"; +import { Mouse } from "../input"; +import { line } from "../utils/canvas"; + +export type Shape = "rectangle" | "ellipse" | "line" | "arrow"; + +export let currentShape: Shape = "rectangle"; + +export function setShape(shape: Shape) { + currentShape = shape; +} + +export let shapeFill = false; + +export function setShapeFill(fill: boolean) { + shapeFill = fill; +} + +export const ShapeTool: ToolDefinition = { + draggingFrom: { x: 0, y: 0 }, + isDragging: false, + + onMouseMove() { + if (!Mouse.down) return; + + if (!this.isDragging) { + this.draggingFrom.x = Mouse.x; + this.draggingFrom.y = Mouse.y; + this.isDragging = true; + } + + shapeCanvas.clearRect(0, 0, shapeCanvas.canvas.width, shapeCanvas.canvas.height); + this.draw(); + }, + + onMouseUp() { + if (!this.isDragging) return; + + shapeCanvas.clearRect(0, 0, shapeCanvas.canvas.width, shapeCanvas.canvas.height); + this.draw(brushCanvas); + this.isDragging = false; + }, + + onMouseMoveListener: null, + onMouseUpListener: null, + + draw(canvas = shapeCanvas) { + canvas.lineCap = "butt"; + canvas.lineJoin = "miter"; + + switch (currentShape) { + case "rectangle": + if (shapeFill) canvas.fillRect(this.draggingFrom.x, this.draggingFrom.y, Mouse.x - this.draggingFrom.x, Mouse.y - this.draggingFrom.y); + else canvas.strokeRect(this.draggingFrom.x, this.draggingFrom.y, Mouse.x - this.draggingFrom.x, Mouse.y - this.draggingFrom.y); + break; + case "ellipse": + const width = Mouse.x - this.draggingFrom.x; + const height = Mouse.y - this.draggingFrom.y; + const centerX = this.draggingFrom.x + width / 2; + const centerY = this.draggingFrom.y + height / 2; + const radiusX = Math.abs(width / 2); + const radiusY = Math.abs(height / 2); + canvas.beginPath(); + canvas.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2); + if (shapeFill) canvas.fill(); + else canvas.stroke(); + break; + case "line": + line(this.draggingFrom.x, this.draggingFrom.y, Mouse.x, Mouse.y, canvas); + break; + case "arrow": + line(this.draggingFrom.x, this.draggingFrom.y, Mouse.x, Mouse.y, canvas); + // draw arrowhead (thanks copilot :3) + const angle = Math.atan2(Mouse.y - this.draggingFrom.y, Mouse.x - this.draggingFrom.x); + const arrowLength = 10; + canvas.beginPath(); + canvas.moveTo(Mouse.x, Mouse.y); + canvas.lineTo(Mouse.x - arrowLength * Math.cos(angle - Math.PI / 6), Mouse.y - arrowLength * Math.sin(angle - Math.PI / 6)); + canvas.lineTo(Mouse.x - arrowLength * Math.cos(angle + Math.PI / 6), Mouse.y - arrowLength * Math.sin(angle + Math.PI / 6)); + canvas.closePath(); + if (shapeFill) canvas.fill(); + else canvas.stroke(); + break; + } + + render(); + }, + + selected() { + this.onMouseMoveListener = this.onMouseMove.bind(this); + this.onMouseUpListener = this.onMouseUp.bind(this); + + Mouse.event.on("move", this.onMouseMoveListener); + Mouse.event.on("up", this.onMouseUpListener); + }, + unselected() { + shapeCanvas.clearRect(0, 0, shapeCanvas.canvas.width, shapeCanvas.canvas.height); + + Mouse.event.off("move", this.onMouseMoveListener); + Mouse.event.off("up", this.onMouseUpListener); + }, +}; diff --git a/src/equicordplugins/remix/editor/utils/canvas.ts b/src/equicordplugins/remix/editor/utils/canvas.ts new file mode 100644 index 00000000..56864bb8 --- /dev/null +++ b/src/equicordplugins/remix/editor/utils/canvas.ts @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { brushCanvas } from "../components/Canvas"; + +export function fillCircle(x: number, y: number, radius: number, canvas = brushCanvas) { + canvas.beginPath(); + canvas.arc(x, y, radius, 0, Math.PI * 2); + canvas.fill(); +} + +export function strokeCircle(x: number, y: number, radius: number, canvas = brushCanvas) { + canvas.beginPath(); + canvas.arc(x, y, radius, 0, Math.PI * 2); + canvas.stroke(); +} + +export function line(x1: number, y1: number, x2: number, y2: number, canvas = brushCanvas) { + canvas.beginPath(); + canvas.moveTo(x1, y1); + canvas.lineTo(x2, y2); + canvas.stroke(); +} + +export function dist(x1: number, y1: number, x2: number, y2: number) { + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); +} + +export function widthFromBounds(bounds: { left: number, right: number, top: number, bottom: number; }) { + return bounds.right - bounds.left; +} + +export function heightFromBounds(bounds: { left: number, right: number, top: number, bottom: number; }) { + return bounds.bottom - bounds.top; +} + +export async function urlToImage(url: string) { + return new Promise(resolve => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.src = url; + }); +} + +export function imageToBlob(image: HTMLImageElement) { + return new Promise(resolve => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + canvas.width = image.width; + canvas.height = image.height; + ctx.drawImage(image, 0, 0); + + canvas.toBlob(blob => { + if (!blob) return; + + resolve(new File([blob], "image.png", { type: "image/png" })); + }); + }); +} diff --git a/src/equicordplugins/remix/editor/utils/eventEmitter.ts b/src/equicordplugins/remix/editor/utils/eventEmitter.ts new file mode 100644 index 00000000..23665ced --- /dev/null +++ b/src/equicordplugins/remix/editor/utils/eventEmitter.ts @@ -0,0 +1,56 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export class EventEmitter { + events: { + [key: string]: ((val: T) => void)[]; + }; + + constructor() { + this.events = {}; + } + + on(eventName: string, callback: (val: T) => void) { + if (!this.events[eventName]) { + this.events[eventName] = []; + } + + this.events[eventName].push(callback); + } + + emit(eventName: string, val: T) { + if (!this.events[eventName]) { + return; + } + + this.events[eventName].forEach(callback => { + callback(val); + }); + } + + off(eventName: string, callback: (val: T) => void) { + if (!this.events[eventName]) { + return; + } + + this.events[eventName] = this.events[eventName].filter(cb => { + return cb !== callback; + }); + } + + clear() { + this.events = {}; + } + + once(eventName: string, callback: (val: T) => void) { + const onceCallback = (val: T) => { + callback(val); + this.off(eventName, onceCallback); + }; + + this.on(eventName, onceCallback); + } +} diff --git a/src/equicordplugins/remix/icons/SendIcon.tsx b/src/equicordplugins/remix/icons/SendIcon.tsx new file mode 100644 index 00000000..0592673c --- /dev/null +++ b/src/equicordplugins/remix/icons/SendIcon.tsx @@ -0,0 +1,11 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const SendIcon = () => { + return ( + + ); +}; diff --git a/src/equicordplugins/remix/index.tsx b/src/equicordplugins/remix/index.tsx new file mode 100644 index 00000000..5e0b1f51 --- /dev/null +++ b/src/equicordplugins/remix/index.tsx @@ -0,0 +1,130 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { disableStyle, enableStyle } from "@api/Styles"; +import { EquicordDevs } from "@utils/constants"; +import { getCurrentChannel } from "@utils/discord"; +import { closeModal, openModal } from "@utils/modal"; +import definePlugin, { OptionType } from "@utils/types"; +import { extractAndLoadChunksLazy, findByPropsLazy, findStoreLazy } from "@webpack"; +import { FluxDispatcher, Menu, MessageActions, RestAPI, showToast, SnowflakeUtils, Toasts } from "@webpack/common"; + +import RemixModal from "./RemixModal"; +import css from "./styles.css?managed"; + +const requireCreateStickerModal = extractAndLoadChunksLazy(["stickerInspected]:"]); +const requireSettingsMenu = extractAndLoadChunksLazy(['name:"UserSettings"'], /createPromise:.{0,20}(\i\.\i\("?.+?"?\).*?).then\(\i\.bind\(\i,"?(.+?)"?\)\).{0,50}"UserSettings"/); + +const CloudUtils = findByPropsLazy("CloudUpload"); +const PendingReplyStore = findStoreLazy("PendingReplyStore"); + + +const validMediaTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; + +const UploadContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { + if (children.find(c => c?.props?.id === "vc-remix")) return; + + children.push( { + const key = openModal(props => + closeModal(key)} /> + ); + }} + />); +}; + +const MessageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { + const url = props.itemHref ?? props.itemSrc; + if (!url) return; + if (props.attachment && !validMediaTypes.includes(props.attachment.content_type)) return; + + const group = findGroupChildrenByChildId("copy-text", children); + if (!group) return; + if (group.find(c => c?.props?.id === "vc-remix")) return; + + const index = group.findIndex(c => c?.props?.id === "copy-text"); + + group.splice(index + 1, 0, { + const key = openModal(modalProps => + closeModal(key)} url={url} /> + ); + }} + />); +}; + +export function sendRemix(blob: Blob) { + const currentChannelId = getCurrentChannel()?.id; + const reply = PendingReplyStore.getPendingReply(currentChannelId); + if (reply) FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", currentChannelId }); + + const upload = new CloudUtils.CloudUpload({ + file: new File([blob], "remix.png", { type: "image/png" }), + isClip: false, + isThumbnail: false, + platform: 1 + }, currentChannelId, false, 0); + + upload.on("complete", () => { + RestAPI.post({ + url: `/channels/${currentChannelId}/messages`, + body: { + channel_id: currentChannelId, + content: "", + nonce: SnowflakeUtils.fromTimestamp(Date.now()), + sticker_ids: [], + attachments: [{ + id: "0", + filename: upload.filename, + uploaded_filename: upload.uploadedFilename, + size: blob.size, + is_remix: settings.store.remixTag + }], + message_reference: reply ? MessageActions.getSendMessageOptionsForReply(reply)?.messageReference : null, + }, + }); + }); + upload.on("error", () => showToast("Failed to upload remix", Toasts.Type.FAILURE)); + + upload.upload(); +} + +const settings = definePluginSettings({ + remixTag: { + description: "Include the remix tag in remixed messages", + type: OptionType.BOOLEAN, + default: true, + }, +}); + +export default definePlugin({ + name: "Remix", + description: "Adds Remix to Desktop", + authors: [EquicordDevs.MrDiamond], + settings, + contextMenus: { + "channel-attach": UploadContextMenuPatch, + "message": MessageContextMenuPatch, + }, + + async start() { + + await requireCreateStickerModal(); + await requireSettingsMenu(); + + enableStyle(css); + }, + + stop() { + disableStyle(css); + }, +}); diff --git a/src/equicordplugins/remix/styles.css b/src/equicordplugins/remix/styles.css new file mode 100644 index 00000000..6f81aa98 --- /dev/null +++ b/src/equicordplugins/remix/styles.css @@ -0,0 +1,57 @@ +.vc-remix-toolbar { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.vc-remix-tools, +.vc-remix-misc, +.vc-remix-settings { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-bottom: 5px; + width: 100%; + background-color: var(--modal-footer-background); + padding: 10px 0; + border-radius: 8px; +} + +.vc-remix-settings { + flex-direction: column; +} + +.vc-remix-setting-section { + display: flex; + flex-direction: row; + justify-content: center; + width: 75%; +} + +.vc-remix-toolbar button { + min-width: 100px; + height: 40px; + background-color: var(--brand); + color: var(--text-primary); + border-radius: 8px; + margin: 0 3px; +} + +.vc-remix-canvas { + max-width: 100%; + max-height: 100%; + min-width: 50%; + min-height: 50%; +} + +.vc-remix-editor { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 10px; + padding-bottom: 10px; +} diff --git a/src/plugins/_core/supportHelper.tsx b/src/plugins/_core/supportHelper.tsx index cd3372de..9f9ea56a 100644 --- a/src/plugins/_core/supportHelper.tsx +++ b/src/plugins/_core/supportHelper.tsx @@ -129,6 +129,10 @@ function generatePluginList() { content += `**Enabled UserPlugins (${enabledUserPlugins.length}):**\n${makeCodeblock(enabledUserPlugins.join(", "))}`; } + if (enabledPlugins.length > 75) { + content = "We don't support users with more than 75 plugins enabled. Please disable some and try again."; + } + return content; } diff --git a/src/plugins/hideAttachments/index.tsx b/src/plugins/hideAttachments/index.tsx index 26f6a47a..82b2daeb 100644 --- a/src/plugins/hideAttachments/index.tsx +++ b/src/plugins/hideAttachments/index.tsx @@ -93,7 +93,7 @@ export default definePlugin({ }, shouldHide(messageId: string) { - return hiddenMessages?.has(messageId) || false; + return hiddenMessages.has(messageId) || false; },