diff --git a/src/plugins/remix/RemixModal.tsx b/src/plugins/remix/RemixModal.tsx new file mode 100644 index 000000000..d6228eaf1 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/Editor.tsx b/src/plugins/remix/editor/Editor.tsx new file mode 100644 index 000000000..a96fd9c11 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/components/Canvas.tsx b/src/plugins/remix/editor/components/Canvas.tsx new file mode 100644 index 000000000..39e1bb422 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/components/SettingColorComponent.tsx b/src/plugins/remix/editor/components/SettingColorComponent.tsx new file mode 100644 index 000000000..4c364edfa --- /dev/null +++ b/src/plugins/remix/editor/components/SettingColorComponent.tsx @@ -0,0 +1,51 @@ +/* + * 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/plugins/remix/editor/components/Toolbar.tsx b/src/plugins/remix/editor/components/Toolbar.tsx new file mode 100644 index 000000000..dac61b823 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/components/colorStyles.css b/src/plugins/remix/editor/components/colorStyles.css new file mode 100644 index 000000000..4f92002ed --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/input.ts b/src/plugins/remix/editor/input.ts new file mode 100644 index 000000000..d47a38461 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/tools/brush.ts b/src/plugins/remix/editor/tools/brush.ts new file mode 100644 index 000000000..213476655 --- /dev/null +++ b/src/plugins/remix/editor/tools/brush.ts @@ -0,0 +1,28 @@ +/* + * 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"; +import { line } from "../utils/canvas"; + +export const BrushTool: ToolDefinition = { + onMouseMove() { + if (!Mouse.down) return; + + 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/plugins/remix/editor/tools/crop.ts b/src/plugins/remix/editor/tools/crop.ts new file mode 100644 index 000000000..37f2b70be --- /dev/null +++ b/src/plugins/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, 15, cropCanvas); + fillCircle(bounds.right, bounds.top, 15, cropCanvas); + fillCircle(bounds.left, bounds.bottom, 15, cropCanvas); + fillCircle(bounds.right, bounds.bottom, 15, 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/plugins/remix/editor/tools/eraser.ts b/src/plugins/remix/editor/tools/eraser.ts new file mode 100644 index 000000000..fcae31512 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/tools/shape.ts b/src/plugins/remix/editor/tools/shape.ts new file mode 100644 index 000000000..2f0a05f34 --- /dev/null +++ b/src/plugins/remix/editor/tools/shape.ts @@ -0,0 +1,112 @@ +/* + * 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; + } + + canvas.lineCap = "butt"; + canvas.lineJoin = "miter"; + + 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/plugins/remix/editor/utils/canvas.ts b/src/plugins/remix/editor/utils/canvas.ts new file mode 100644 index 000000000..56864bb80 --- /dev/null +++ b/src/plugins/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/plugins/remix/editor/utils/eventEmitter.ts b/src/plugins/remix/editor/utils/eventEmitter.ts new file mode 100644 index 000000000..23665ced3 --- /dev/null +++ b/src/plugins/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/plugins/remix/icons/SendIcon.tsx b/src/plugins/remix/icons/SendIcon.tsx new file mode 100644 index 000000000..0592673c0 --- /dev/null +++ b/src/plugins/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/plugins/remix/index.tsx b/src/plugins/remix/index.tsx new file mode 100644 index 000000000..cc9bade81 --- /dev/null +++ b/src/plugins/remix/index.tsx @@ -0,0 +1,119 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { disableStyle, enableStyle } from "@api/Styles"; +import { Devs } from "@utils/constants"; +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 { Util } from "Vencord"; + +import RemixModal from "./RemixModal"; +import css from "./styles.css?managed"; + +// so FileUpload is loaded +export const requireCreateStickerModal = extractAndLoadChunksLazy(["stickerInspected]:"]); + +const CloudUtils = findByPropsLazy("CloudUpload"); +const PendingReplyStore = findStoreLazy("PendingReplyStore"); + +const UploadContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { + if (children.find(c => c?.props?.id === "vc-remix")) return; + + children.push( { + const key = openModal(props => + closeModal(key)} /> + ); + }} + />); +}; + +const ImageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { + if (!props.attachment || children.find(c => c?.props?.id === "vc-remix")) return; + + children.push( { + const key = openModal(modalProps => + closeModal(key)} url={props.attachment.url} /> + ); + }} + />); +}; + +export function sendRemix(blob: Blob) { + const currentChannelId = Util.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: [Devs.MrDiamond], + settings, + + async start() { + addContextMenuPatch("channel-attach", UploadContextMenuPatch); + addContextMenuPatch("message", ImageContextMenuPatch); + + await requireCreateStickerModal(); + + enableStyle(css); + }, + + stop() { + removeContextMenuPatch("channel-attach", UploadContextMenuPatch); + removeContextMenuPatch("message", ImageContextMenuPatch); + + disableStyle(css); + }, +}); diff --git a/src/plugins/remix/styles.css b/src/plugins/remix/styles.css new file mode 100644 index 000000000..f0d186f7c --- /dev/null +++ b/src/plugins/remix/styles.css @@ -0,0 +1,55 @@ +.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%; +} + +.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/utils/constants.ts b/src/utils/constants.ts index 899936128..f63ef7b42 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -410,6 +410,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ coolelectronics: { name: "coolelectronics", id: 696392247205298207n, + }, + MrDiamond: { + name: "MrDiamond", + id: 523338295644782592n, } } satisfies Record);