Compare commits
70 commits
main
...
feat/userc
Author | SHA1 | Date | |
---|---|---|---|
|
85b510b881 | ||
|
c25e8ac8c1 | ||
|
34e1c83756 | ||
|
ea864b9a00 | ||
|
d7e5c06e83 | ||
|
39860bd05c | ||
|
e3aab2b864 | ||
|
66a27a1e79 | ||
|
58c6611abe | ||
|
fc10bc1e69 | ||
|
f4b846375f | ||
|
6881ddbea7 | ||
|
c981325fb3 | ||
|
cbdaf7daa6 | ||
|
f68351b31b | ||
|
a911dd17b1 | ||
|
867730a478 | ||
|
828a882017 | ||
|
a57ab38c8c | ||
|
d544d33564 | ||
|
dc3591ba18 | ||
|
c0f786804a | ||
|
31fd035bd3 | ||
|
4dbffcb8b8 | ||
|
c12dd258a6 | ||
|
b6547b463b | ||
|
b7cdb96e09 | ||
|
ff32014613 | ||
|
791eaa06d4 | ||
|
25857377b6 | ||
|
eb31ad994e | ||
|
6fbe24a268 | ||
|
5bc24a5d78 | ||
|
91e093a21d | ||
|
f8232694e7 | ||
|
03bc5cde22 | ||
|
4325dcf02e | ||
|
1179a9f5a1 | ||
|
7105558640 | ||
|
c019a3cc10 | ||
|
a79fb2718b | ||
|
06f2239b1a | ||
|
1be6738715 | ||
|
12509f8157 | ||
|
74f9b1a022 | ||
|
482caf0c5b | ||
|
b1bdc48769 | ||
|
141b1a7041 | ||
|
d43eebe0e4 | ||
|
f2dc34e023 | ||
|
f596941f3a | ||
|
e4f4802155 | ||
|
b7bd5096b6 | ||
|
9fdd2c7c17 | ||
|
51059c29e7 | ||
|
64848b2fbf | ||
|
b6e20680ff | ||
|
d361edc47d | ||
|
7174d2e744 | ||
|
9a23571b3e | ||
|
723191ba9b | ||
|
0cc420fb45 | ||
|
a939034bc1 | ||
|
b350087a7a | ||
|
19a87e3e94 | ||
|
c0dff86cb2 | ||
|
0d66604be5 | ||
|
b7fb178f1f | ||
|
d689b3273b | ||
|
2ef2baafbe |
30 changed files with 1033 additions and 56 deletions
|
@ -25,7 +25,7 @@ import * as DataStore from "../src/api/DataStore";
|
|||
import { debounce } from "../src/utils";
|
||||
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
|
||||
import { getTheme, Theme } from "../src/utils/discord";
|
||||
import { getThemeInfo } from "../src/main/themes";
|
||||
import { getThemeInfo } from "../src/utils/themes/bd";
|
||||
|
||||
// Discord deletes this so need to store in variable
|
||||
const { localStorage } = window;
|
||||
|
|
|
@ -39,15 +39,19 @@
|
|||
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
|
||||
"monaco-editor": "^0.43.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"usercss-meta": "^0.12.0",
|
||||
"virtual-merge": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.246",
|
||||
"@types/diff": "^5.0.3",
|
||||
"@types/less": "^3.0.4",
|
||||
"@types/lodash": "^4.14.194",
|
||||
"@types/node": "^18.16.3",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.1",
|
||||
"@types/stylus": "^0.48.39",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/yazl": "^2.4.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||
"@typescript-eslint/parser": "^5.59.1",
|
||||
|
@ -75,7 +79,8 @@
|
|||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
|
||||
"eslint@8.46.0": "patches/eslint@8.46.0.patch"
|
||||
"eslint@8.46.0": "patches/eslint@8.46.0.patch",
|
||||
"@types/less@3.0.4": "patches/@types__less@3.0.4.patch"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
|
|
13
patches/@types__less@3.0.4.patch
Normal file
13
patches/@types__less@3.0.4.patch
Normal file
|
@ -0,0 +1,13 @@
|
|||
diff --git a/index.d.ts b/index.d.ts
|
||||
index eb4f07d47b932fb9cc8c8cd451ab107f648bd013..18a3e15a1997734e1773718e5be55d252ed9478c 100644
|
||||
--- a/index.d.ts
|
||||
+++ b/index.d.ts
|
||||
@@ -306,7 +306,5 @@ interface LessStatic {
|
||||
}
|
||||
|
||||
declare module "less" {
|
||||
- export = less;
|
||||
+ export = LessStatic;
|
||||
}
|
||||
-
|
||||
-declare var less: LessStatic;
|
|
@ -1,6 +1,9 @@
|
|||
lockfileVersion: '6.0'
|
||||
|
||||
patchedDependencies:
|
||||
'@types/less@3.0.4':
|
||||
hash: krcufrsfhsuxuoj7hocqugs6zi
|
||||
path: patches/@types__less@3.0.4.patch
|
||||
eslint-plugin-path-alias@1.0.0:
|
||||
hash: m6sma4g6bh67km3q6igf6uxaja
|
||||
path: patches/eslint-plugin-path-alias@1.0.0.patch
|
||||
|
@ -33,6 +36,9 @@ dependencies:
|
|||
nanoid:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
usercss-meta:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
virtual-merge:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
|
@ -44,6 +50,9 @@ devDependencies:
|
|||
'@types/diff':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
'@types/less':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi)
|
||||
'@types/lodash':
|
||||
specifier: ^4.14.194
|
||||
version: 4.14.194
|
||||
|
@ -56,6 +65,12 @@ devDependencies:
|
|||
'@types/react-dom':
|
||||
specifier: ^18.2.1
|
||||
version: 18.2.1
|
||||
'@types/stylus':
|
||||
specifier: ^0.48.39
|
||||
version: 0.48.39
|
||||
'@types/tinycolor2':
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3
|
||||
'@types/yazl':
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
|
@ -564,6 +579,11 @@ packages:
|
|||
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
||||
dev: true
|
||||
|
||||
/@types/less@3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi):
|
||||
resolution: {integrity: sha512-djlMpTdDF+tLaqVpK/0DWGNIr7BFjN8ykDLkgS0sQGYYLop51imRRE3foTjl+dMAH1zFE8bMZAG0VbYPEcSgsA==}
|
||||
dev: true
|
||||
patched: true
|
||||
|
||||
/@types/lodash@4.14.194:
|
||||
resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
|
||||
dev: true
|
||||
|
@ -613,6 +633,16 @@ packages:
|
|||
resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==}
|
||||
dev: true
|
||||
|
||||
/@types/stylus@0.48.39:
|
||||
resolution: {integrity: sha512-98a0QrJorrq8+Vsan9yfxol2Qr6nvUWBeV3oYnSMks4QdLMebAzZvRd9IuoZOcnB6Erfjcjn1J2J+63MPCxJnw==}
|
||||
dependencies:
|
||||
'@types/node': 18.16.3
|
||||
dev: true
|
||||
|
||||
/@types/tinycolor2@1.4.3:
|
||||
resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==}
|
||||
dev: true
|
||||
|
||||
/@types/yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
|
||||
requiresBuild: true
|
||||
|
@ -3309,6 +3339,11 @@ packages:
|
|||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/usercss-meta@0.12.0:
|
||||
resolution: {integrity: sha512-zKrXCKdpeIwtVe87omxGo9URf+7mbozduMZEg79dmT4KB3XJwfIkEi/Uk0PcTwR/nZLtAK1+k7isgbGB/g6E7Q==}
|
||||
engines: {node: '>=8.3'}
|
||||
dev: false
|
||||
|
||||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
dev: true
|
||||
|
|
|
@ -8,7 +8,6 @@ import { IpcEvents } from "@utils/IpcEvents";
|
|||
import { IpcRes } from "@utils/types";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { PluginIpcMappings } from "main/ipcPlugins";
|
||||
import type { UserThemeHeader } from "main/themes";
|
||||
|
||||
function invoke<T = any>(event: IpcEvents, ...args: any[]) {
|
||||
return ipcRenderer.invoke(event, ...args) as Promise<T>;
|
||||
|
@ -33,7 +32,7 @@ export default {
|
|||
uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData),
|
||||
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
|
||||
getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR),
|
||||
getThemesList: () => invoke<Array<UserThemeHeader>>(IpcEvents.GET_THEMES_LIST),
|
||||
getThemesList: () => invoke<Array<{ fileName: string; content: string; }>>(IpcEvents.GET_THEMES_LIST),
|
||||
getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName),
|
||||
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
|
||||
},
|
||||
|
|
|
@ -75,6 +75,12 @@ export interface Settings {
|
|||
settingsSync: boolean;
|
||||
settingsSyncVersion: number;
|
||||
};
|
||||
|
||||
userCssVars: {
|
||||
[fileName: string]: {
|
||||
[varName: string]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DefaultSettings: Settings = {
|
||||
|
@ -107,7 +113,9 @@ const DefaultSettings: Settings = {
|
|||
url: "https://api.vencord.dev/",
|
||||
settingsSync: false,
|
||||
settingsSyncVersion: 0
|
||||
}
|
||||
},
|
||||
|
||||
userCssVars: {}
|
||||
};
|
||||
|
||||
try {
|
||||
|
@ -125,7 +133,7 @@ const saveSettingsOnFrequentAction = debounce(async () => {
|
|||
}
|
||||
}, 60_000);
|
||||
|
||||
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; };
|
||||
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; _exact?: boolean; };
|
||||
const subscriptions = new Set<SubscriptionCallback>();
|
||||
|
||||
const proxyCache = {} as Record<string, any>;
|
||||
|
@ -182,7 +190,12 @@ function makeProxy(settings: any, root = settings, path = ""): Settings {
|
|||
const setPath = `${path}${path && "."}${p}`;
|
||||
delete proxyCache[setPath];
|
||||
for (const subscription of subscriptions) {
|
||||
if (!subscription._paths || subscription._paths.includes(setPath)) {
|
||||
if (
|
||||
!subscription._paths ||
|
||||
(subscription._exact
|
||||
? subscription._paths.includes(setPath)
|
||||
: subscription._paths.some(p => setPath.startsWith(p)))
|
||||
) {
|
||||
subscription(v, setPath);
|
||||
}
|
||||
}
|
||||
|
@ -220,11 +233,14 @@ export const Settings = makeProxy(settings);
|
|||
* @returns Settings
|
||||
*/
|
||||
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
|
||||
export function useSettings(paths?: UseSettings<Settings>[]) {
|
||||
export function useSettings(paths?: UseSettings<Settings>[], exact = true) {
|
||||
const [, forceUpdate] = React.useReducer(() => ({}), {});
|
||||
|
||||
const onUpdate: SubscriptionCallback = paths
|
||||
? (value, path) => paths.includes(path as UseSettings<Settings>) && forceUpdate()
|
||||
? (value, path) =>
|
||||
(exact
|
||||
? paths.includes(path as UseSettings<Settings>)
|
||||
: paths.some(p => path.startsWith(p))) && forceUpdate()
|
||||
: forceUpdate;
|
||||
|
||||
React.useEffect(() => {
|
||||
|
@ -250,11 +266,12 @@ type ResolvePropDeep<T, P> = P extends "" ? T :
|
|||
* @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`))
|
||||
* addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled"))
|
||||
*/
|
||||
export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void;
|
||||
export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void): void;
|
||||
export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) {
|
||||
export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void, exact?: boolean): void;
|
||||
export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void, exact?: boolean): void;
|
||||
export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void, exact = true) {
|
||||
if (path)
|
||||
((onUpdate as SubscriptionCallback)._paths ??= []).push(path);
|
||||
(onUpdate as SubscriptionCallback)._exact = exact;
|
||||
subscriptions.add(onUpdate);
|
||||
}
|
||||
|
||||
|
|
|
@ -256,6 +256,24 @@ export function DeleteIcon(props: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin icon, created by CorellanStoma. https://github.com/CreArts-Community/Settings-Icons
|
||||
*/
|
||||
export function PluginIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={classes(props.className, "vc-plugin-icon")}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
|
|
|
@ -16,23 +16,31 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import "./themesStyles.css";
|
||||
|
||||
import { Settings, useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { DeleteIcon } from "@components/Icons";
|
||||
import { CogWheel, DeleteIcon, PluginIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { AddonCard } from "@components/VencordSettings/AddonCard";
|
||||
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
|
||||
import { openInviteModal } from "@utils/discord";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { openModal } from "@utils/modal";
|
||||
import { showItemInFolder } from "@utils/native";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import type { ThemeHeader } from "@utils/themes";
|
||||
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
|
||||
import { usercssParse } from "@utils/themes/usercss";
|
||||
import { findByPropsLazy, findLazy } from "@webpack";
|
||||
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
|
||||
import { UserThemeHeader } from "main/themes";
|
||||
import { Button, Card, Forms, React, showToast, TabBar, TextArea, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
|
||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||
import type { UserstyleHeader } from "usercss-meta";
|
||||
|
||||
import { AddonCard } from "./AddonCard";
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
import { isPluginEnabled } from "../../plugins";
|
||||
import { UserCSSSettingsModal } from "./UserCSSModal";
|
||||
|
||||
type FileInput = ComponentType<{
|
||||
ref: Ref<HTMLInputElement>;
|
||||
|
@ -47,6 +55,7 @@ const TextAreaProps = findLazy(m => typeof m.textarea === "string");
|
|||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
|
||||
function Validator({ link }: { link: string; }) {
|
||||
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
|
||||
if (res.status > 300) throw `${res.status} ${res.statusText}`;
|
||||
|
@ -95,14 +104,73 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
|
|||
);
|
||||
}
|
||||
|
||||
interface ThemeCardProps {
|
||||
interface OtherThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
|
||||
interface UserCSSCardProps {
|
||||
theme: UserstyleHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function UserCSSThemeCard({ theme, enabled, onChange, onDelete }: UserCSSCardProps) {
|
||||
const missingPlugins = useMemo(() =>
|
||||
theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]);
|
||||
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name ?? "Unknown"}
|
||||
description={theme.description}
|
||||
author={theme.author ?? "Unknown"}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
infoButton={
|
||||
<>
|
||||
{missingPlugins && missingPlugins.length > 0 && (
|
||||
<Tooltip text={"The following plugins are required, but aren't enabled: " + missingPlugins.join(", ")}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<div
|
||||
style={{ color: "var(--status-warning" }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<PluginIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
{theme.vars && (
|
||||
<div style={{ cursor: "pointer" }} onClick={
|
||||
() => openModal(modalProps =>
|
||||
<UserCSSSettingsModal modalProps={modalProps} theme={theme} />)
|
||||
}>
|
||||
<CogWheel />
|
||||
</div>
|
||||
)}
|
||||
{IS_WEB && (
|
||||
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
|
||||
{!!theme.homepageURL && <Link href={theme.homepageURL}>Homepage</Link>}
|
||||
{!!(theme.homepageURL && theme.supportURL) && " • "}
|
||||
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherThemeCard({ theme, enabled, onChange, onDelete }: OtherThemeCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
|
@ -149,7 +217,7 @@ function ThemesTab() {
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
||||
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
|
||||
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
|
||||
const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
|
||||
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -158,7 +226,57 @@ function ThemesTab() {
|
|||
|
||||
async function refreshLocalThemes() {
|
||||
const themes = await VencordNative.themes.getThemesList();
|
||||
setUserThemes(themes);
|
||||
|
||||
const themeInfo: ThemeHeader[] = [];
|
||||
|
||||
for (const { fileName, content } of themes) {
|
||||
if (!fileName.endsWith(".css")) continue;
|
||||
|
||||
if ((!IS_WEB || "armcord" in window) && fileName.endsWith(".user.css")) {
|
||||
// handle it as usercss
|
||||
const header = await usercssParse(content, fileName);
|
||||
|
||||
themeInfo.push({
|
||||
type: "usercss",
|
||||
header
|
||||
});
|
||||
|
||||
Settings.userCssVars[header.id] ??= {};
|
||||
|
||||
for (const [name, varInfo] of Object.entries(header.vars ?? {})) {
|
||||
let normalizedValue = "";
|
||||
|
||||
switch (varInfo.type) {
|
||||
case "text":
|
||||
case "color":
|
||||
normalizedValue = varInfo.default;
|
||||
break;
|
||||
case "select":
|
||||
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
|
||||
break;
|
||||
case "checkbox":
|
||||
normalizedValue = varInfo.default ? "1" : "0";
|
||||
break;
|
||||
case "range":
|
||||
normalizedValue = `${varInfo.default}${varInfo.units}`;
|
||||
break;
|
||||
case "number":
|
||||
normalizedValue = String(varInfo.default);
|
||||
break;
|
||||
}
|
||||
|
||||
Settings.userCssVars[header.id][name] ??= normalizedValue;
|
||||
}
|
||||
} else {
|
||||
// presumably BD but could also be plain css
|
||||
themeInfo.push({
|
||||
type: "other",
|
||||
header: getThemeInfo(stripBOM(content), fileName)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setUserThemes(themeInfo);
|
||||
}
|
||||
|
||||
// When a local theme is enabled/disabled, update the settings
|
||||
|
@ -252,8 +370,9 @@ function ThemesTab() {
|
|||
</Card>
|
||||
|
||||
<div className={cl("grid")}>
|
||||
{userThemes?.map(theme => (
|
||||
<ThemeCard
|
||||
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
|
||||
type === "other" ? (
|
||||
<OtherThemeCard
|
||||
key={theme.fileName}
|
||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||
|
@ -262,9 +381,21 @@ function ThemesTab() {
|
|||
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
theme={theme}
|
||||
theme={theme as UserThemeHeader}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
<UserCSSThemeCard
|
||||
key={theme.fileName}
|
||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||
onDelete={async () => {
|
||||
onLocalThemeChange(theme.fileName, false);
|
||||
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
theme={theme as UserstyleHeader}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
</>
|
114
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
114
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||
import { Text } from "@webpack/common";
|
||||
import type { ReactNode } from "react";
|
||||
import { UserstyleHeader } from "usercss-meta";
|
||||
|
||||
import { SettingBooleanComponent, SettingColorComponent, SettingNumberComponent, SettingRangeComponent, SettingSelectComponent, SettingTextComponent } from "./components";
|
||||
|
||||
interface UserCSSSettingsModalProps {
|
||||
modalProps: ModalProps;
|
||||
theme: UserstyleHeader;
|
||||
}
|
||||
|
||||
export function UserCSSSettingsModal({ modalProps, theme }: UserCSSSettingsModalProps) {
|
||||
// @ts-expect-error UseSettings<> can't determine this is a valid key
|
||||
const themeSettings = useSettings(["userCssVars"], false).userCssVars[theme.id];
|
||||
|
||||
const controls: ReactNode[] = [];
|
||||
|
||||
for (const [name, varInfo] of Object.entries(theme.vars)) {
|
||||
switch (varInfo.type) {
|
||||
case "text": {
|
||||
controls.push(
|
||||
<SettingTextComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "checkbox": {
|
||||
controls.push(
|
||||
<SettingBooleanComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "color": {
|
||||
controls.push(
|
||||
<SettingColorComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "number": {
|
||||
controls.push(
|
||||
<SettingNumberComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "select": {
|
||||
controls.push(
|
||||
<SettingSelectComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
options={varInfo.options}
|
||||
default={varInfo.default}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "range": {
|
||||
controls.push(
|
||||
<SettingRangeComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
default={varInfo.default}
|
||||
min={varInfo.min}
|
||||
max={varInfo.max}
|
||||
step={varInfo.step}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalRoot {...modalProps}>
|
||||
<ModalHeader separator={false}>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</Text>
|
||||
<ModalCloseButton onClick={modalProps.onClose} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>{controls}</Flex>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, Switch, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: boolean) {
|
||||
const corrected = value ? "1" : "0";
|
||||
|
||||
setValue(corrected);
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Switch
|
||||
key={name}
|
||||
value={value === "1"}
|
||||
onChange={handleChange}
|
||||
hideBorder
|
||||
style={{ marginBottom: "0.5em" }}
|
||||
>
|
||||
{label}
|
||||
</Switch>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./colorStyles.css";
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { findByCodeLazy, findComponentByCodeLazy } from "@webpack";
|
||||
import { Forms, useMemo, useState } from "@webpack/common";
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: number | null;
|
||||
showEyeDropper?: boolean;
|
||||
onChange(value: number | null): void;
|
||||
}
|
||||
const ColorPicker = findComponentByCodeLazy<ColorPickerProps>(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
|
||||
|
||||
// TinyColor is completely unmangled and it's duplicated in two modules! Fun!
|
||||
const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType=");
|
||||
|
||||
const cl = classNameFactory("vc-usercss-settings-color-");
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingColorComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: number) {
|
||||
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
|
||||
|
||||
setValue(corrected);
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
const normalizedValue = useMemo(() => parseInt(TinyColor(value).toHex(), 16), [value]);
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<div className={cl("swatch-row")}>
|
||||
<span>{label}</span>
|
||||
<ColorPicker
|
||||
key={name}
|
||||
color={normalizedValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, TextInput, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setValue(value);
|
||||
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="number"
|
||||
pattern="-?[0-9]+"
|
||||
key={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, Slider, useMemo, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingRangeComponent({ label, name, default: def, min, max, step, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: number) {
|
||||
const corrected = value.toString();
|
||||
|
||||
setValue(corrected);
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
const markers = useMemo(() => {
|
||||
const markers: number[] = [];
|
||||
|
||||
// defaults taken from https://github.com/openstyles/stylus/wiki/Writing-UserCSS#default-value
|
||||
for (let i = (min ?? 0); i <= (max ?? 10); i += (step ?? 1)) {
|
||||
markers.push(i);
|
||||
}
|
||||
|
||||
return markers;
|
||||
}, [min, max, step]);
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||
<Slider
|
||||
initialValue={parseInt(value, 10)}
|
||||
defaultValue={def}
|
||||
onValueChange={handleChange}
|
||||
minValue={min}
|
||||
maxValue={max}
|
||||
|
||||
markers={markers}
|
||||
stickToMarkers={true}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { identity } from "@utils/misc";
|
||||
import { ComponentTypes, Forms, Select, useMemo, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
options: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
default: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingSelectComponent({ label, name, options, default: def, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setValue(value);
|
||||
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
const opts = useMemo(() => options.map(option => ({
|
||||
disabled: false,
|
||||
|
||||
key: option.name,
|
||||
value: option.value,
|
||||
default: def === option.name,
|
||||
label: option.label
|
||||
} satisfies ComponentTypes.SelectOption)), [options, def]);
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||
<Select
|
||||
placeholder={label}
|
||||
key={name}
|
||||
options={opts}
|
||||
closeOnSelect={true}
|
||||
|
||||
select={handleChange}
|
||||
isSelected={v => v === value}
|
||||
serialize={identity}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, TextInput, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingTextComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setValue(value);
|
||||
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||
<TextInput
|
||||
key={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
19
src/components/ThemeSettings/components/colorStyles.css
Normal file
19
src/components/ThemeSettings/components/colorStyles.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.vc-usercss-settings-color-swatch-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vc-usercss-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;
|
||||
}
|
12
src/components/ThemeSettings/components/index.ts
Normal file
12
src/components/ThemeSettings/components/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from "./SettingBooleanComponent";
|
||||
export * from "./SettingColorComponent";
|
||||
export * from "./SettingNumberComponent";
|
||||
export * from "./SettingRangeComponent";
|
||||
export * from "./SettingSelectComponent";
|
||||
export * from "./SettingTextComponent";
|
|
@ -17,7 +17,6 @@
|
|||
*/
|
||||
|
||||
import "./settingsStyles.css";
|
||||
import "./themesStyles.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { handleComponentFailed } from "@components/handleComponentFailed";
|
||||
|
|
|
@ -29,7 +29,6 @@ import { join, normalize } from "path";
|
|||
|
||||
import monacoHtml from "~fileContent/monacoWin.html;base64";
|
||||
|
||||
import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes";
|
||||
import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants";
|
||||
import { makeLinksOpenExternally } from "./utils/externalLinks";
|
||||
|
||||
|
@ -47,21 +46,11 @@ function readCss() {
|
|||
return readFile(QUICKCSS_PATH, "utf-8").catch(() => "");
|
||||
}
|
||||
|
||||
async function listThemes(): Promise<UserThemeHeader[]> {
|
||||
const files = await readdir(THEMES_DIR).catch(() => []);
|
||||
|
||||
const themeInfo: UserThemeHeader[] = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
if (!fileName.endsWith(".css")) continue;
|
||||
|
||||
const data = await getThemeData(fileName).then(stripBOM).catch(() => null);
|
||||
if (data == null) continue;
|
||||
|
||||
themeInfo.push(getThemeInfo(data, fileName));
|
||||
}
|
||||
|
||||
return themeInfo;
|
||||
function listThemes(): Promise<{ fileName: string; content: string; }[]> {
|
||||
return readdir(THEMES_DIR)
|
||||
.then(files =>
|
||||
Promise.all(files.map(async fileName => ({ fileName, content: await getThemeData(fileName) }))))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
function getThemeData(fileName: string) {
|
||||
|
|
|
@ -101,7 +101,7 @@ export default definePlugin({
|
|||
{
|
||||
section: "VencordThemes",
|
||||
label: "Themes",
|
||||
element: require("@components/VencordSettings/ThemesTab").default,
|
||||
element: require("@components/ThemeSettings/ThemesTab").default,
|
||||
className: "vc-themes"
|
||||
},
|
||||
!IS_UPDATER_DISABLED && {
|
||||
|
|
|
@ -16,6 +16,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type StylusRenderer = require("stylus/lib/renderer");
|
||||
import type LessStatic from "less";
|
||||
|
||||
import { makeLazy } from "./lazy";
|
||||
import { EXTENSION_BASE_URL } from "./web-metadata";
|
||||
|
||||
|
@ -84,3 +87,18 @@ export const shikiOnigasmSrc = "https://unpkg.com/@vap/shiki@0.10.3/dist/onig.wa
|
|||
|
||||
// @ts-expect-error
|
||||
export const getStegCloak = /* #__PURE__*/ makeLazy(() => import("https://unpkg.com/stegcloak-dist@1.0.0/index.js"));
|
||||
|
||||
export const getStylus = /* #__PURE__*/ makeLazy(async () => {
|
||||
const stylusScript = await fetch("https://unpkg.com/stylus-lang-bundle@0.58.1/dist/stylus-renderer.min.js").then(r => r.text());
|
||||
// the stylus bundle doesn't have a header that checks for export conditions so we can just patch the script to
|
||||
// return the renderer itself
|
||||
const patchedScript = stylusScript.replace("var StylusRenderer=", "return ");
|
||||
return Function(patchedScript)() as typeof StylusRenderer;
|
||||
});
|
||||
|
||||
export const getLess = /* #__PURE__*/ makeLazy(async () => {
|
||||
const lessScript = await fetch("https://unpkg.com/less@4.2.0/dist/less.min.js").then(r => r.text());
|
||||
const module = { exports: {} };
|
||||
Function("module", "exports", lessScript)(module, module.exports);
|
||||
return module.exports as LessStatic;
|
||||
});
|
||||
|
|
|
@ -17,6 +17,9 @@
|
|||
*/
|
||||
|
||||
import { addSettingsListener, Settings } from "@api/Settings";
|
||||
import { Toasts } from "@webpack/common";
|
||||
|
||||
import { compileUsercss } from "./themes/usercss/compiler";
|
||||
|
||||
|
||||
let style: HTMLStyleElement;
|
||||
|
@ -65,12 +68,36 @@ async function initThemes() {
|
|||
for (const theme of enabledThemes) {
|
||||
const themeData = await VencordNative.themes.getThemeData(theme);
|
||||
if (!themeData) continue;
|
||||
|
||||
const blob = new Blob([themeData], { type: "text/css" });
|
||||
links.push(URL.createObjectURL(blob));
|
||||
}
|
||||
} else {
|
||||
const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`);
|
||||
links.push(...localThemes);
|
||||
for (const theme of enabledThemes) if (!theme.endsWith(".user.css")) {
|
||||
links.push(`vencord:///themes/${theme}?v=${Date.now()}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!IS_WEB || "armcord" in window) {
|
||||
for (const theme of enabledThemes) if (theme.endsWith(".user.css")) {
|
||||
// UserCSS goes through a compile step first
|
||||
const css = await compileUsercss(theme);
|
||||
if (!css) {
|
||||
// let's not leave the user in the dark about this and point them to where they can find the error
|
||||
Toasts.show({
|
||||
message: `Failed to compile ${theme}, check the console for more info.`,
|
||||
type: Toasts.Type.FAILURE,
|
||||
id: Toasts.genId(),
|
||||
options: {
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blob = new Blob([css], { type: "text/css" });
|
||||
links.push(URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
|
||||
themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n");
|
||||
|
@ -85,6 +112,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
addSettingsListener("themeLinks", initThemes);
|
||||
addSettingsListener("enabledThemes", initThemes);
|
||||
addSettingsListener("userCssVars", initThemes, false);
|
||||
|
||||
if (!IS_WEB)
|
||||
VencordNative.quickCss.addThemeChangeListener(initThemes);
|
||||
|
|
17
src/utils/themes/index.ts
Normal file
17
src/utils/themes/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { UserstyleHeader } from "usercss-meta";
|
||||
|
||||
import type { UserThemeHeader } from "./bd";
|
||||
|
||||
export type ThemeHeader = {
|
||||
type: "other";
|
||||
header: UserThemeHeader;
|
||||
} | {
|
||||
type: "usercss";
|
||||
header: UserstyleHeader;
|
||||
};
|
109
src/utils/themes/usercss/compiler.ts
Normal file
109
src/utils/themes/usercss/compiler.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import { getLess, getStylus } from "@utils/dependencies";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
||||
import { usercssParse } from ".";
|
||||
|
||||
const UserCSSLogger = new Logger("UserCSS:Compiler", "#d2acf5");
|
||||
|
||||
const preprocessors: { [preprocessor: string]: (text: string, vars: Record<string, string>) => Promise<string>; } = {
|
||||
async default(text: string, vars: Record<string, string>) {
|
||||
const variables = Object.entries(vars)
|
||||
.map(([name, value]) => `--${name}: ${value}`)
|
||||
.join("; ");
|
||||
|
||||
return `/* ==Vencord== */\n:root{${variables}}\n/* ==/Vencord== */${text}`;
|
||||
},
|
||||
|
||||
async uso(text: string, vars: Record<string, string>) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
text = text.replace(new RegExp(`\\/\\*\\[\\[${k}\\]\\]\\*\\/`, "g"), v);
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
|
||||
async stylus(text: string, vars: Record<string, string>) {
|
||||
const StylusRenderer = await getStylus();
|
||||
|
||||
const variables = Object.entries(vars)
|
||||
.map(([name, value]) => `${name} = ${value}`)
|
||||
.join("\n");
|
||||
|
||||
const stylusDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`;
|
||||
|
||||
return new StylusRenderer(stylusDoc).render();
|
||||
},
|
||||
|
||||
async less(text: string, vars: Record<string, string>) {
|
||||
const less = await getLess();
|
||||
|
||||
const variables = Object.entries(vars)
|
||||
.map(([name, value]) => `@${name}: ${value};`)
|
||||
.join("\n");
|
||||
|
||||
const lessDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`;
|
||||
|
||||
return less.render(lessDoc).then(r => r.css);
|
||||
}
|
||||
};
|
||||
|
||||
export async function compileUsercss(fileName: string) {
|
||||
const themeData = await VencordNative.themes.getThemeData(fileName);
|
||||
if (!themeData) return null;
|
||||
|
||||
// UserCSS preprocessor order look like this:
|
||||
// - use the preprocessor defined
|
||||
// - if variables are set, `uso`
|
||||
// - otherwise, `default`
|
||||
const { vars = {}, preprocessor = Object.keys(vars).length > 0 ? "uso" : "default", id } = await usercssParse(themeData, fileName);
|
||||
|
||||
const preprocessorFn = preprocessors[preprocessor];
|
||||
|
||||
if (!preprocessorFn) {
|
||||
UserCSSLogger.error("File", fileName, "requires preprocessor", preprocessor, "which isn't known to Vencord");
|
||||
return null;
|
||||
}
|
||||
|
||||
const varsToPass = {};
|
||||
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
varsToPass[k] = Settings.userCssVars[id]?.[k] ?? v.default;
|
||||
|
||||
switch (v.type) {
|
||||
case "checkbox": {
|
||||
if (["less", "stylus"].includes(preprocessor)) {
|
||||
varsToPass[k] = varsToPass[k] === "1" ? "true" : "false";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "range": {
|
||||
varsToPass[k] = `${varsToPass[k]}${v.units ?? "px"}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "text": {
|
||||
if (preprocessor === "stylus") {
|
||||
varsToPass[k] = `"${varsToPass[k].replace(/"/g, "\" + '\"' + \"")}"`;
|
||||
} else {
|
||||
varsToPass[k] = `"${varsToPass[k].replace(/\//g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await preprocessorFn(themeData, varsToPass);
|
||||
} catch (error) {
|
||||
UserCSSLogger.error("File", fileName, "failed to compile with preprocessor", preprocessor, error);
|
||||
return null;
|
||||
}
|
||||
}
|
44
src/utils/themes/usercss/index.ts
Normal file
44
src/utils/themes/usercss/index.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { parse as originalParse, UserstyleHeader } from "usercss-meta";
|
||||
|
||||
const UserCSSLogger = new Logger("UserCSS", "#d2acf5");
|
||||
|
||||
export async function usercssParse(text: string, fileName: string): Promise<UserstyleHeader> {
|
||||
const { metadata, errors } = originalParse(text.replace(/\r/g, ""), {
|
||||
allowErrors: true,
|
||||
unknownKey: "assign"
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
UserCSSLogger.warn("Parsed", fileName, "with errors:", errors);
|
||||
}
|
||||
|
||||
const requiredPlugins = metadata["vc-requiredPlugins"]?.split(",").map(p => p.trim());
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
fileName,
|
||||
id: await getUserCssId(metadata),
|
||||
requiredPlugins
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserCssId(header: UserstyleHeader): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const nameBuf = encoder.encode(header.name);
|
||||
const namespaceBuf = encoder.encode(header.namespace);
|
||||
|
||||
const nameHash = new Uint8Array(await window.crypto.subtle.digest("SHA-256", nameBuf));
|
||||
const namespaceHash = new Uint8Array(await window.crypto.subtle.digest("SHA-256", namespaceBuf));
|
||||
|
||||
const idHash = await window.crypto.subtle.digest("SHA-256", new Uint8Array([...nameHash, ...namespaceHash]));
|
||||
|
||||
return window.btoa(String.fromCharCode(...new Uint8Array(idHash))).substring(0, 43); // base64 adds one more padding character
|
||||
}
|
134
src/utils/themes/usercss/usercss-meta.d.ts
vendored
Normal file
134
src/utils/themes/usercss/usercss-meta.d.ts
vendored
Normal file
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare module "usercss-meta" {
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
export type UserCSSVariable = Simplify<{ name: string; label: string; } & (
|
||||
| {
|
||||
type: "text";
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "color";
|
||||
// Hex, rgb(), rgba()
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "checkbox";
|
||||
default: boolean;
|
||||
}
|
||||
| {
|
||||
type: "range";
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
units?: string;
|
||||
}
|
||||
| {
|
||||
type: "number";
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
type: "select";
|
||||
default: string;
|
||||
options: { name: string; label: string; value: string; }[];
|
||||
}
|
||||
)>;
|
||||
|
||||
export interface UserstyleHeader {
|
||||
/**
|
||||
* The unique ID of the UserCSS style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The file name of the UserCSS style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* The required plugins for this style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
* @see {@link vc-requiredPlugins}
|
||||
*/
|
||||
requiredPlugins?: string[];
|
||||
|
||||
/**
|
||||
* The name of your style.
|
||||
*
|
||||
* The combination of {@link name} and {@link namespace} must be unique.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The namespace of the style. Helps to distinguish between styles with the same name.
|
||||
*
|
||||
* The combination of {@link name} and {@link namespace} must be unique.
|
||||
*/
|
||||
namespace: string;
|
||||
/**
|
||||
* The version of your style.
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* A short significant description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The author of the style.
|
||||
*/
|
||||
author?: string;
|
||||
/**
|
||||
* The project's homepage.
|
||||
*
|
||||
* This is not an update URL. See {@link updateURL}.
|
||||
*/
|
||||
homepageURL?: string;
|
||||
/**
|
||||
* The URL the user can report issues to the style author.
|
||||
*/
|
||||
supportURL?: string;
|
||||
/**
|
||||
* The URL used when updating the style.
|
||||
*/
|
||||
updateURL?: string;
|
||||
/**
|
||||
* The SPDX license identifier for this style. If none is included, the style is assumed to be All Rights Reserved.
|
||||
*/
|
||||
license?: string;
|
||||
/**
|
||||
* The CSS preprocessor used to write this style.
|
||||
*/
|
||||
preprocessor?: "default" | "uso" | "less" | "stylus";
|
||||
|
||||
/**
|
||||
* A list of variables the style defines.
|
||||
*/
|
||||
vars: Record<string, UserCSSVariable>;
|
||||
|
||||
/**
|
||||
* Required plugins for this style to work. Comma-separated list of plugin names.
|
||||
*
|
||||
* @vencord This is a Vencord-specific extension, however we wish for this to become a standard for client mods
|
||||
* to implement, hence the more generic namespaced name.
|
||||
*/
|
||||
"vc-requiredPlugins"?: string;
|
||||
}
|
||||
|
||||
type UserCSSParseOptions = {
|
||||
allowErrors: boolean;
|
||||
unknownKey: "assign";
|
||||
};
|
||||
|
||||
export function parse(text: string, options: UserCSSParseOptions): { metadata: UserstyleHeader; errors: { code: string; args: any; }[]; };
|
||||
}
|
2
src/webpack/common/types/components.d.ts
vendored
2
src/webpack/common/types/components.d.ts
vendored
|
@ -191,7 +191,7 @@ export type TextArea = ComponentType<PropsWithRef<Omit<HTMLProps<HTMLTextAreaEle
|
|||
onChange(v: string): void;
|
||||
}>>;
|
||||
|
||||
interface SelectOption {
|
||||
export interface SelectOption {
|
||||
disabled?: boolean;
|
||||
value: any;
|
||||
label: string;
|
||||
|
|
Loading…
Reference in a new issue