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)} /> + + + + + + + + + + ); +} 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 ( +
+
+ + + + +
+
+
+ {(tool === "brush" || tool === "shape") && + + } + + {(tool === "brush" || tool === "erase" || tool === "shape") && + + } +
+ {(tool === "crop") && } +
+ {(tool === "shape") && (<> +