From 4c1f7fa4daa9681115ca7dd264eec0d9356b1dc4 Mon Sep 17 00:00:00 2001 From: yuna0x0 Date: Sat, 16 Nov 2024 03:15:59 +0800 Subject: [PATCH] feat(themes): Online Themes CSS Override --- src/api/Settings.ts | 4 + .../VencordSettings/ThemeOverrideModal.tsx | 290 ++++++++++++++++++ src/components/VencordSettings/ThemesTab.tsx | 37 ++- src/utils/constants.ts | 4 + src/utils/quickCss.ts | 6 +- 5 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 src/components/VencordSettings/ThemeOverrideModal.tsx diff --git a/src/api/Settings.ts b/src/api/Settings.ts index ac116f547e8..20548c6c823 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -35,6 +35,9 @@ export interface Settings { enableReactDevtools: boolean; themeLinks: string[]; enabledThemes: string[]; + onlineThemeOverrides: { + [rawLink: string]: string; + }; frameless: boolean; transparent: boolean; winCtrlQ: boolean; @@ -82,6 +85,7 @@ const DefaultSettings: Settings = { useQuickCss: true, themeLinks: [], enabledThemes: [], + onlineThemeOverrides: {}, enableReactDevtools: false, frameless: false, transparent: false, diff --git a/src/components/VencordSettings/ThemeOverrideModal.tsx b/src/components/VencordSettings/ThemeOverrideModal.tsx new file mode 100644 index 00000000000..9b1bc677f1f --- /dev/null +++ b/src/components/VencordSettings/ThemeOverrideModal.tsx @@ -0,0 +1,290 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { useSettings } from "@api/Settings"; +import { PencilIcon, RestartIcon } from "@components/Icons"; +import { Margins } from "@utils/margins"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Button, Card, Forms, React, Text, TextInput, useEffect, useState } from "@webpack/common"; + +interface CssRules { + [selector: string]: { + [property: string]: string; + }; +} + +interface CssRuleCardProps { + selector: string; + properties: [string, string][]; + overrideRules: CssRules; + setOverrideRules: React.Dispatch>; + settings: any; + rawLink: string; +} + +interface ThemeOverrideModalProps extends ModalProps { + rawCssText: string; + rawLink: string; +} + +function getCssPropertyValue(style: CSSStyleDeclaration, prop: string): string | null { + const value = style.getPropertyValue(prop); + if (!value) return null; + + const priority = style.getPropertyPriority(prop); + return `${value}${priority ? " !" + priority : ""}`; +} + +function applyRulesToSheet(sheet: CSSStyleSheet, rules: CssRules) { + Object.entries(rules).forEach(([sel, props]) => { + const rule = `${sel} { ${Object.entries(props) + .map(([p, v]) => `${p}: ${v}`) + .join("; ")} }`; + try { + sheet.insertRule(rule); + } catch (e) { + console.error("Failed to insert rule:", rule, e); + } + }); +} + +function updateCssRule(rules: CssRules, selector: string, property: string, value: string) { + const newRules = { ...rules }; + + if (!value.trim()) { + if (newRules[selector]) { + delete newRules[selector][property]; + if (Object.keys(newRules[selector]).length === 0) { + delete newRules[selector]; + } + } + } else { + if (!newRules[selector]) { + newRules[selector] = {}; + } + newRules[selector][property] = value; + } + + return newRules; +} + +function updateThemeOverrides(settings: any, rawLink: string, rules: CssRules) { + if (Object.keys(rules).length === 0) { + delete settings.onlineThemeOverrides[rawLink]; + } else { + const sheet = new CSSStyleSheet(); + applyRulesToSheet(sheet, rules); + settings.onlineThemeOverrides[rawLink] = Array.from(sheet.cssRules) + .map(rule => rule.cssText).join(" "); + } + settings.onlineThemeOverrides = { ...settings.onlineThemeOverrides }; +} + +function CssRuleCard({ selector, properties, overrideRules, setOverrideRules, settings, rawLink }: CssRuleCardProps) { + const updateValue = (prop: string, value: string, shouldCommit = false) => { + setOverrideRules(prev => { + const newRules = updateCssRule(prev, selector, prop, value); + if (shouldCommit) { + updateThemeOverrides(settings, rawLink, newRules); + } + return newRules; + }); + }; + + return ( + + + {selector} +
{ + properties.forEach(([prop]) => updateValue(prop, "", true)); + }} + > + +
+
+
+ {properties.map(([prop, value], propIndex) => { + const inputRef = React.useRef(null); + + return ( + + {prop}: +
+ + updateValue(prop, v, false)} + onBlur={e => updateValue(prop, e.currentTarget.value, true)} + style={{ + fontFamily: "var(--font-code)", + marginBottom: ".5em", + flex: 1, + minWidth: "200px", + width: `${Math.max(200, value.length * 8)}px` + }} + /> + +
{ + updateValue(prop, value, true); + inputRef.current?.focus(); + }} + > + +
+
{ + updateValue(prop, "", true); + inputRef.current?.focus(); + }} + > + +
+
+
+ ); + })} +
+
+ ); +} + +function ThemeOverrideModal({ rawCssText, rawLink, onClose, transitionState }: ThemeOverrideModalProps) { + const settings = useSettings(); + const [cssRules, setCssRules] = useState(null); + const [overrideRules, setOverrideRules] = useState({}); + + useEffect(() => { + const themeSheet = new CSSStyleSheet(); + themeSheet.replaceSync(rawCssText); + setCssRules(themeSheet.cssRules); + + if (settings.onlineThemeOverrides[rawLink]) { + const savedOverrideSheet = new CSSStyleSheet(); + savedOverrideSheet.replaceSync(settings.onlineThemeOverrides[rawLink]); + + const savedOverrideRules: CssRules = {}; + const themeSelectors = new Set(Array.from(themeSheet.cssRules) + .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) + .map(rule => rule.selectorText)); + + Array.from(savedOverrideSheet.cssRules).forEach(rule => { + if (!(rule instanceof CSSStyleRule)) return; + + const overrideSelector = rule.selectorText; + if (!themeSelectors.has(overrideSelector)) return; + + const themeRule = Array.from(themeSheet.cssRules) + .find((r): r is CSSStyleRule => + r instanceof CSSStyleRule && r.selectorText === overrideSelector + ); + if (!themeRule) return; + + const themeProperties = new Set(Array.from(themeRule.style)); + savedOverrideRules[overrideSelector] = {}; + + for (const prop of rule.style) { + if (!themeProperties.has(prop)) continue; + + const value = getCssPropertyValue(rule.style, prop); + if (value) { + savedOverrideRules[overrideSelector][prop] = value; + } + } + + if (Object.keys(savedOverrideRules[overrideSelector]).length === 0) { + delete savedOverrideRules[overrideSelector]; + } + }); + + updateThemeOverrides(settings, rawLink, savedOverrideRules); + setOverrideRules(savedOverrideRules); + } + }, [rawCssText]); + + if (!cssRules) return null; + + return ( + + + Theme CSS Override + + + + + {rawLink} + + + + + + {Array.from(cssRules || []).map((rule, index) => { + if (!(rule instanceof CSSStyleRule)) return null; + + const selector = rule.selectorText; + const properties: [string, string][] = []; + + for (const prop of rule.style) { + const value = getCssPropertyValue(rule.style, prop); + if (value) { + properties.push([prop, value]); + } + } + + return ( + + ); + })} + + + + + + + ); +} + +export function openThemeOverrideModal(rawCssText: string, rawLink: string) { + openModal(modalProps => ( + + )); +} diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index f718ab11f04..dfdc1db3f1d 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -28,7 +28,7 @@ import { Margins } from "@utils/margins"; import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; import { findLazy } from "@webpack"; -import { Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; +import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; import type { ComponentType, Ref, SyntheticEvent } from "react"; import Plugins from "~plugins"; @@ -36,6 +36,7 @@ import Plugins from "~plugins"; import { AddonCard } from "./AddonCard"; import { QuickAction, QuickActionCard } from "./quickActions"; import { SettingsTab, wrapTab } from "./shared"; +import { openThemeOverrideModal } from "./ThemeOverrideModal"; type FileInput = ComponentType<{ ref: Ref; @@ -48,15 +49,19 @@ const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue & const cl = classNameFactory("vc-settings-theme-"); -function Validator({ link }: { link: string; }) { - const [res, err, pending] = useAwaiter(() => fetch(link).then(res => { +async function fetchOnlineThemes(link: string) { + return fetch(link).then(res => { if (res.status > 300) throw `${res.status} ${res.statusText}`; const contentType = res.headers.get("Content-Type"); if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain")) throw "Not a CSS file. Remember to use the raw link!"; - return "Okay!"; - })); + return res.text(); + }); +} + +function Validator({ link, rawLink }: { link: string; rawLink: string; }) { + const [res, err, pending] = useAwaiter(() => fetchOnlineThemes(link)); const text = pending ? "Checking..." @@ -64,9 +69,20 @@ function Validator({ link }: { link: string; }) { ? `Error: ${err instanceof Error ? err.message : String(err)}` : "Valid!"; - return {text}; + return ( + <> + {text} + {!err && !pending && res && ( + <> + + + )} + + ); } function Validators({ themeLinks }: { themeLinks: string[]; }) { @@ -96,7 +112,7 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) { }}> {label} - + ; })} @@ -296,6 +312,9 @@ function ThemesTab() { .map(s => s.trim()) .filter(Boolean) )]; + settings.onlineThemeOverrides = Object.fromEntries( + Object.entries(settings.onlineThemeOverrides).filter(([key]) => settings.themeLinks.includes(key)) + ); } function renderOnlineThemes() { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 362a22deed5..ca50187613f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -583,6 +583,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "jamesbt365", id: 158567567487795200n, }, + yuna0x0: { + name: "yuna0x0", + id: 213656926414831616n, + }, } satisfies Record); // iife so #__PURE__ works correctly diff --git a/src/utils/quickCss.ts b/src/utils/quickCss.ts index 6a18948d155..f2a13cc2d8c 100644 --- a/src/utils/quickCss.ts +++ b/src/utils/quickCss.ts @@ -58,7 +58,7 @@ export async function toggle(isEnabled: boolean) { async function initThemes() { themesStyle ??= createStyle("vencord-themes"); - const { themeLinks, enabledThemes } = Settings; + const { themeLinks, enabledThemes, onlineThemeOverrides } = Settings; // "darker" and "midnight" both count as dark const activeTheme = ThemeStore.theme === "light" ? "light" : "dark"; @@ -85,7 +85,8 @@ async function initThemes() { links.push(...localThemes); } - themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n"); + themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n") + + "\n" + Object.values(onlineThemeOverrides).join("\n"); } document.addEventListener("DOMContentLoaded", () => { @@ -97,6 +98,7 @@ document.addEventListener("DOMContentLoaded", () => { SettingsStore.addChangeListener("themeLinks", initThemes); SettingsStore.addChangeListener("enabledThemes", initThemes); + SettingsStore.addChangeListener("onlineThemeOverrides", initThemes); ThemeStore.addChangeListener(initThemes); if (!IS_WEB)