mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-11 07:33:05 -04:00
Remix Plugin
This commit is contained in:
parent
dad1464d4f
commit
517164ea97
25 changed files with 1103 additions and 242 deletions
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 (
|
||||
<svg className={className} height={height} width={width} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" >
|
||||
<path fill="currentColor" d="M15.56 11.77c.2-.1.44.02.44.23a4 4 0 1 1-4-4c.21 0 .33.25.23.44a2.5 2.5 0 0 0 3.32 3.32Z" />
|
||||
<path fill="currentColor" fillRule="evenodd" d="M22.89 11.7c.07.2.07.4 0 .6C22.27 13.9 19.1 21 12 21c-7.11 0-10.27-7.11-10.89-8.7a.83.83 0 0 1 0-.6C1.73 10.1 4.9 3 12 3c7.11 0 10.27 7.11 10.89 8.7Zm-4.5-3.62A15.11 15.11 0 0 1 20.85 12c-.38.88-1.18 2.47-2.46 3.92C16.87 17.62 14.8 19 12 19c-2.8 0-4.87-1.38-6.39-3.08A15.11 15.11 0 0 1 3.15 12c.38-.88 1.18-2.47 2.46-3.92C7.13 6.38 9.2 5 12 5c2.8 0 4.87 1.38 6.39 3.08Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<svg className={className} height={height} width={width} viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8.06358 19.4719C7.8226 19.7129 7.88837 20.119 8.20282 20.2504C9.31861 20.7166 10.5806 21 12 21C19.1 21 22.27 13.9 22.89 12.3C22.96 12.1 22.96 11.9 22.89 11.7C22.6279 11.0278 21.912 9.36931 20.6355 7.67842C20.4569 7.44184 20.1113 7.42419 19.9017 7.63379L19.1844 8.35115C19.0057 8.52986 18.9892 8.81332 19.139 9.01682C19.7777 9.88405 20.3225 10.8178 20.7633 11.8026C20.8195 11.9282 20.8197 12.0714 20.7631 12.1969C20.3546 13.104 19.5796 14.5725 18.39 15.92C16.87 17.62 14.8 19 12 19C11.0499 19 10.1838 18.8411 9.39472 18.5647C9.20611 18.4986 8.99473 18.5408 8.85341 18.6821L8.06358 19.4719ZM12.2958 15.2397C11.9993 15.5362 12.1829 16.0178 12.5975 15.9551C12.9163 15.907 13.2297 15.8202 13.5307 15.6955C14.2616 15.3928 14.8864 14.8801 15.3259 14.2223C15.7654 13.5645 16 12.7911 16 12C16 11.9755 15.9967 11.9523 15.9907 11.9305C15.9432 11.7599 15.7142 11.8214 15.589 11.9465L12.2958 15.2397ZM12.0655 8.00857C12.2382 8.05521 12.1766 8.28779 12.0502 8.41427L8.76028 11.7042C8.46375 12.0007 7.98224 11.8171 8.04488 11.4025C8.09303 11.0837 8.1798 10.7703 8.30448 10.4693C8.60723 9.73836 9.11992 9.11365 9.77772 8.67412C10.4355 8.2346 11.2089 8 12 8C12.023 8 12.0449 8.00299 12.0655 8.00857ZM15.1466 5.31789C15.0053 5.4592 14.7939 5.50137 14.6053 5.4353C13.8162 5.15891 12.9501 5 12 5C9.2 5 7.13 6.38 5.61 8.08C4.42045 9.42754 3.64546 10.896 3.23687 11.8031C3.18035 11.9286 3.18047 12.0718 3.23671 12.1974C3.67759 13.1822 4.22228 14.116 4.86096 14.9832C5.01083 15.1867 4.99433 15.4702 4.81562 15.6489L4.09827 16.3662C3.88867 16.5758 3.54305 16.5582 3.36446 16.3216C2.08807 14.6307 1.37209 12.9722 1.11 12.3C1.03518 12.107 1.03518 11.893 1.11 11.7C1.73 10.1 4.9 3 12 3C13.4194 3 14.6814 3.28337 15.7972 3.74961C16.1117 3.88101 16.1774 4.28705 15.9365 4.52802L15.1466 5.31789Z" fill="currentColor" />
|
||||
<path d="M4 20L20 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<span className={cl("accessory")}>
|
||||
<HideIcon width={16} height={16} />
|
||||
This message is hidden •{" "}
|
||||
<button onClick={() => revealMessage(id)} className={cl("reveal")}>
|
||||
Reveal
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
|
@ -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<string, {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
}>();
|
||||
|
||||
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(
|
||||
<Menu.MenuItem
|
||||
id={cl("reveal")}
|
||||
label="Reveal Message"
|
||||
icon={EyeIcon}
|
||||
action={() => revealMessage(id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
children.push(<Menu.MenuItem
|
||||
id={cl("hide")}
|
||||
label="Hide Message"
|
||||
color="danger"
|
||||
icon={HideIcon}
|
||||
action={() => {
|
||||
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 <HideMessageAccessory id={message.id} />;
|
||||
return null;
|
||||
});
|
||||
},
|
||||
|
||||
async stop() {
|
||||
for (const id of hiddenMessages.keys()) revealMessage(id);
|
||||
|
||||
removeMessageAccessory("vc-hide-message");
|
||||
|
||||
style.remove();
|
||||
hiddenMessages.clear();
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
}
|
54
src/equicordplugins/remix/RemixModal.tsx
Normal file
54
src/equicordplugins/remix/RemixModal.tsx
Normal file
|
@ -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 (
|
||||
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
|
||||
<ModalHeader>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Remix</Text>
|
||||
<ModalCloseButton onClick={() => closeModal(close)} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Editor url={url} />
|
||||
</ModalContent>
|
||||
<ModalFooter className="vc-remix-modal-footer">
|
||||
<Button onClick={() => closeModal(close, true)} className="vc-remix-send"><SendIcon /> Send</Button>
|
||||
<Button onClick={() => closeModal(close)} color={Button.Colors.RED}>Close</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
44
src/equicordplugins/remix/editor/Editor.tsx
Normal file
44
src/equicordplugins/remix/editor/Editor.tsx
Normal file
|
@ -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<File | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.url) return;
|
||||
|
||||
urlToImage(props.url).then(img => {
|
||||
imageToBlob(img).then(blob => {
|
||||
setFile(new File([blob], "remix.png"));
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="vc-remix-editor">
|
||||
{!file && <FileUpload
|
||||
filename={undefined}
|
||||
placeholder="Choose an image"
|
||||
buttonText="Browse"
|
||||
filters={[{ name: "Image", extensions: ["png", "jpeg"] }]}
|
||||
onFileSelect={(file: File) => setFile(file)}
|
||||
/>}
|
||||
{file && (<>
|
||||
<Toolbar />
|
||||
<Canvas file={file!} />
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
};
|
82
src/equicordplugins/remix/editor/components/Canvas.tsx
Normal file
82
src/equicordplugins/remix/editor/components/Canvas.tsx
Normal file
|
@ -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<Blob> {
|
||||
return new Promise<Blob>(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<HTMLCanvasElement>(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 (<canvas ref={canvasRef} className="vc-remix-canvas"></canvas>);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
|
@ -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<ColorPickerProps>(".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 (
|
||||
<Forms.FormSection>
|
||||
<div className={cl("swatch-row")}>
|
||||
<ColorPicker
|
||||
key={name}
|
||||
color={color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
141
src/equicordplugins/remix/editor/components/Toolbar.tsx
Normal file
141
src/equicordplugins/remix/editor/components/Toolbar.tsx
Normal file
|
@ -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<Tool, ToolDefinition | undefined> = {
|
||||
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<Tool>(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 (
|
||||
<div className="vc-remix-toolbar">
|
||||
<div className="vc-remix-tools">
|
||||
<Button className={(tool === "brush" ? "tool-active" : "")} onClick={() => changeTool("brush")}>Brush</Button>
|
||||
<Button className={(tool === "erase" ? "tool-active" : "")} onClick={() => changeTool("erase")}>Erase</Button>
|
||||
<Button className={(tool === "crop" ? "tool-active" : "")} onClick={() => changeTool("crop")}>Crop</Button>
|
||||
<Button className={(tool === "shape" ? "tool-active" : "")} onClick={() => changeTool("shape")}>Shape</Button>
|
||||
</div>
|
||||
<div className="vc-remix-settings">
|
||||
<div className="vc-remix-setting-section">
|
||||
{(tool === "brush" || tool === "shape") &&
|
||||
<SettingColorComponent name="vc-remix-color-picker" onChange={setColor} color={colorStringToHex(color)} />
|
||||
}
|
||||
|
||||
{(tool === "brush" || tool === "erase" || tool === "shape") &&
|
||||
<Slider
|
||||
minValue={1}
|
||||
maxValue={500}
|
||||
initialValue={size}
|
||||
onValueChange={setSize}
|
||||
markers={[1, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500]}
|
||||
hideBubble
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{(tool === "crop") && <Button onClick={resetBounds}>Reset</Button>}
|
||||
<div className="vc-remix-setting-section">
|
||||
{(tool === "shape") && (<>
|
||||
<Select
|
||||
select={setShape}
|
||||
isSelected={v => v === currentShape}
|
||||
serialize={v => String(v)}
|
||||
placeholder="Shape"
|
||||
options={
|
||||
["Rectangle", "Ellipse", "Line", "Arrow"].map(v => ({
|
||||
label: v,
|
||||
value: v.toLowerCase() as Shape,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<Forms.FormText className="vc-remix-setting-switch">Fill <Switch checked={fill} onChange={setFill} /></Forms.FormText>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-remix-misc">
|
||||
<Button onClick={clear}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
19
src/equicordplugins/remix/editor/components/colorStyles.css
Normal file
19
src/equicordplugins/remix/editor/components/colorStyles.css
Normal file
|
@ -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;
|
||||
}
|
57
src/equicordplugins/remix/editor/input.ts
Normal file
57
src/equicordplugins/remix/editor/input.ts
Normal file
|
@ -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<MouseEvent>()
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
31
src/equicordplugins/remix/editor/tools/brush.ts
Normal file
31
src/equicordplugins/remix/editor/tools/brush.ts
Normal file
|
@ -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);
|
||||
},
|
||||
};
|
151
src/equicordplugins/remix/editor/tools/crop.ts
Normal file
151
src/equicordplugins/remix/editor/tools/crop.ts
Normal file
|
@ -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);
|
||||
},
|
||||
};
|
36
src/equicordplugins/remix/editor/tools/eraser.ts
Normal file
36
src/equicordplugins/remix/editor/tools/eraser.ts
Normal file
|
@ -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);
|
||||
},
|
||||
};
|
109
src/equicordplugins/remix/editor/tools/shape.ts
Normal file
109
src/equicordplugins/remix/editor/tools/shape.ts
Normal file
|
@ -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);
|
||||
},
|
||||
};
|
63
src/equicordplugins/remix/editor/utils/canvas.ts
Normal file
63
src/equicordplugins/remix/editor/utils/canvas.ts
Normal file
|
@ -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<HTMLImageElement>(resolve => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => resolve(img);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function imageToBlob(image: HTMLImageElement) {
|
||||
return new Promise<File>(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" }));
|
||||
});
|
||||
});
|
||||
}
|
56
src/equicordplugins/remix/editor/utils/eventEmitter.ts
Normal file
56
src/equicordplugins/remix/editor/utils/eventEmitter.ts
Normal file
|
@ -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<T> {
|
||||
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);
|
||||
}
|
||||
}
|
11
src/equicordplugins/remix/icons/SendIcon.tsx
Normal file
11
src/equicordplugins/remix/icons/SendIcon.tsx
Normal file
|
@ -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 (<svg className="sendIcon__461ff" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.6 10.02 14 11.4a.6.6 0 0 1 0 1.18L6.6 14l-2.94 5.87a1.48 1.48 0 0 0 1.99 1.98l17.03-8.52a1.48 1.48 0 0 0 0-2.64L5.65 2.16a1.48 1.48 0 0 0-1.99 1.98l2.94 5.88Z"></path>
|
||||
</svg>);
|
||||
};
|
130
src/equicordplugins/remix/index.tsx
Normal file
130
src/equicordplugins/remix/index.tsx
Normal file
|
@ -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(<Menu.MenuItem
|
||||
id="vc-remix"
|
||||
label="Remix"
|
||||
action={() => {
|
||||
const key = openModal(props =>
|
||||
<RemixModal modalProps={props} close={() => 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, <Menu.MenuItem
|
||||
id="vc-remix"
|
||||
label="Remix"
|
||||
action={() => {
|
||||
const key = openModal(modalProps =>
|
||||
<RemixModal modalProps={modalProps} close={() => 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);
|
||||
},
|
||||
});
|
57
src/equicordplugins/remix/styles.css
Normal file
57
src/equicordplugins/remix/styles.css
Normal file
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -93,7 +93,7 @@ export default definePlugin({
|
|||
},
|
||||
|
||||
shouldHide(messageId: string) {
|
||||
return hiddenMessages?.has(messageId) || false;
|
||||
return hiddenMessages.has(messageId) || false;
|
||||
|
||||
},
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue