Compare commits
91 commits
main
...
feat/userc
Author | SHA1 | Date | |
---|---|---|---|
269ed18fcc | |||
f793f9eb6b | |||
686bb118b4 | |||
6c551650d0 | |||
3cf60f8daa | |||
e350883b72 | |||
0aa399c8ed | |||
2ae1a70f7f | |||
e947f6aa74 | |||
0b8429f40c | |||
15749dc8ce | |||
02272c5b46 | |||
6a3c592d5f | |||
1c0dff3666 | |||
2cafedc7e7 | |||
9b89ef58be | |||
18b1fe0413 | |||
85bd99c2ca | |||
0413a2399a | |||
42307ccc0e | |||
669c7f3964 | |||
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 |
|
@ -25,7 +25,6 @@ import * as DataStore from "../src/api/DataStore";
|
||||||
import { debounce } from "../src/utils";
|
import { debounce } from "../src/utils";
|
||||||
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
|
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
|
||||||
import { getTheme, Theme } from "../src/utils/discord";
|
import { getTheme, Theme } from "../src/utils/discord";
|
||||||
import { getThemeInfo } from "../src/main/themes";
|
|
||||||
|
|
||||||
// Discord deletes this so need to store in variable
|
// Discord deletes this so need to store in variable
|
||||||
const { localStorage } = window;
|
const { localStorage } = window;
|
||||||
|
@ -46,7 +45,7 @@ window.VencordNative = {
|
||||||
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
|
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
|
||||||
getThemesDir: async () => "",
|
getThemesDir: async () => "",
|
||||||
getThemesList: () => DataStore.entries(themeStore).then(entries =>
|
getThemesList: () => DataStore.entries(themeStore).then(entries =>
|
||||||
entries.map(([name, css]) => getThemeInfo(css, name.toString()))
|
entries.map(([name, css]) => ({ fileName: name as string, content: css }))
|
||||||
),
|
),
|
||||||
getThemeData: (fileName: string) => DataStore.get(fileName, themeStore),
|
getThemeData: (fileName: string) => DataStore.get(fileName, themeStore),
|
||||||
getSystemValues: async () => ({}),
|
getSystemValues: async () => ({}),
|
||||||
|
|
|
@ -39,15 +39,19 @@
|
||||||
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
|
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
|
||||||
"monaco-editor": "^0.43.0",
|
"monaco-editor": "^0.43.0",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
|
"usercss-meta": "^0.12.0",
|
||||||
"virtual-merge": "^1.0.1"
|
"virtual-merge": "^1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.0.246",
|
"@types/chrome": "^0.0.246",
|
||||||
"@types/diff": "^5.0.3",
|
"@types/diff": "^5.0.3",
|
||||||
|
"@types/less": "^3.0.4",
|
||||||
"@types/lodash": "^4.14.194",
|
"@types/lodash": "^4.14.194",
|
||||||
"@types/node": "^18.16.3",
|
"@types/node": "^18.16.3",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.1",
|
"@types/react-dom": "^18.2.1",
|
||||||
|
"@types/stylus": "^0.48.39",
|
||||||
|
"@types/tinycolor2": "^1.4.3",
|
||||||
"@types/yazl": "^2.4.2",
|
"@types/yazl": "^2.4.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||||
"@typescript-eslint/parser": "^5.59.1",
|
"@typescript-eslint/parser": "^5.59.1",
|
||||||
|
@ -75,7 +79,8 @@
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
|
"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": {
|
"peerDependencyRules": {
|
||||||
"ignoreMissing": [
|
"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'
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
|
'@types/less@3.0.4':
|
||||||
|
hash: krcufrsfhsuxuoj7hocqugs6zi
|
||||||
|
path: patches/@types__less@3.0.4.patch
|
||||||
eslint-plugin-path-alias@1.0.0:
|
eslint-plugin-path-alias@1.0.0:
|
||||||
hash: m6sma4g6bh67km3q6igf6uxaja
|
hash: m6sma4g6bh67km3q6igf6uxaja
|
||||||
path: patches/eslint-plugin-path-alias@1.0.0.patch
|
path: patches/eslint-plugin-path-alias@1.0.0.patch
|
||||||
|
@ -33,6 +36,9 @@ dependencies:
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
|
usercss-meta:
|
||||||
|
specifier: ^0.12.0
|
||||||
|
version: 0.12.0
|
||||||
virtual-merge:
|
virtual-merge:
|
||||||
specifier: ^1.0.1
|
specifier: ^1.0.1
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
|
@ -44,6 +50,9 @@ devDependencies:
|
||||||
'@types/diff':
|
'@types/diff':
|
||||||
specifier: ^5.0.3
|
specifier: ^5.0.3
|
||||||
version: 5.0.3
|
version: 5.0.3
|
||||||
|
'@types/less':
|
||||||
|
specifier: ^3.0.4
|
||||||
|
version: 3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi)
|
||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: ^4.14.194
|
specifier: ^4.14.194
|
||||||
version: 4.14.194
|
version: 4.14.194
|
||||||
|
@ -56,6 +65,12 @@ devDependencies:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.2.1
|
specifier: ^18.2.1
|
||||||
version: 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':
|
'@types/yazl':
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.4.2
|
version: 2.4.2
|
||||||
|
@ -564,6 +579,11 @@ packages:
|
||||||
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
||||||
dev: true
|
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:
|
/@types/lodash@4.14.194:
|
||||||
resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
|
resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -613,6 +633,16 @@ packages:
|
||||||
resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==}
|
resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==}
|
||||||
dev: true
|
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:
|
/@types/yauzl@2.10.0:
|
||||||
resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
|
resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
|
@ -3309,6 +3339,11 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
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:
|
/util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { IpcEvents } from "@utils/IpcEvents";
|
||||||
import { IpcRes } from "@utils/types";
|
import { IpcRes } from "@utils/types";
|
||||||
import { ipcRenderer } from "electron";
|
import { ipcRenderer } from "electron";
|
||||||
import { PluginIpcMappings } from "main/ipcPlugins";
|
import { PluginIpcMappings } from "main/ipcPlugins";
|
||||||
import type { UserThemeHeader } from "main/themes";
|
|
||||||
|
|
||||||
function invoke<T = any>(event: IpcEvents, ...args: any[]) {
|
function invoke<T = any>(event: IpcEvents, ...args: any[]) {
|
||||||
return ipcRenderer.invoke(event, ...args) as Promise<T>;
|
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),
|
uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData),
|
||||||
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
|
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
|
||||||
getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR),
|
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),
|
getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName),
|
||||||
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
|
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
|
||||||
},
|
},
|
||||||
|
|
|
@ -75,6 +75,12 @@ export interface Settings {
|
||||||
settingsSync: boolean;
|
settingsSync: boolean;
|
||||||
settingsSyncVersion: number;
|
settingsSyncVersion: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userCssVars: {
|
||||||
|
[themeId: string]: {
|
||||||
|
[varName: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultSettings: Settings = {
|
const DefaultSettings: Settings = {
|
||||||
|
@ -107,7 +113,9 @@ const DefaultSettings: Settings = {
|
||||||
url: "https://api.vencord.dev/",
|
url: "https://api.vencord.dev/",
|
||||||
settingsSync: false,
|
settingsSync: false,
|
||||||
settingsSyncVersion: 0
|
settingsSyncVersion: 0
|
||||||
}
|
},
|
||||||
|
|
||||||
|
userCssVars: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -125,7 +133,7 @@ const saveSettingsOnFrequentAction = debounce(async () => {
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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 subscriptions = new Set<SubscriptionCallback>();
|
||||||
|
|
||||||
const proxyCache = {} as Record<string, any>;
|
const proxyCache = {} as Record<string, any>;
|
||||||
|
@ -182,7 +190,12 @@ function makeProxy(settings: any, root = settings, path = ""): Settings {
|
||||||
const setPath = `${path}${path && "."}${p}`;
|
const setPath = `${path}${path && "."}${p}`;
|
||||||
delete proxyCache[setPath];
|
delete proxyCache[setPath];
|
||||||
for (const subscription of subscriptions) {
|
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);
|
subscription(v, setPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -220,11 +233,14 @@ export const Settings = makeProxy(settings);
|
||||||
* @returns Settings
|
* @returns Settings
|
||||||
*/
|
*/
|
||||||
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
|
// 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 [, forceUpdate] = React.useReducer(() => ({}), {});
|
||||||
|
|
||||||
const onUpdate: SubscriptionCallback = paths
|
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;
|
: forceUpdate;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -250,11 +266,12 @@ type ResolvePropDeep<T, P> = P extends "" ? T :
|
||||||
* @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`))
|
* @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`))
|
||||||
* addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled"))
|
* 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 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): 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) {
|
export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void, exact = true) {
|
||||||
if (path)
|
if (path)
|
||||||
((onUpdate as SubscriptionCallback)._paths ??= []).push(path);
|
((onUpdate as SubscriptionCallback)._paths ??= []).push(path);
|
||||||
|
(onUpdate as SubscriptionCallback)._exact = exact;
|
||||||
subscriptions.add(onUpdate);
|
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) {
|
export function PlusIcon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
|
@ -290,3 +308,40 @@ export function NoEntrySignIcon(props: IconProps) {
|
||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PasteIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
{...props}
|
||||||
|
className={classes(props.className, "vc-paste-icon")}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResetIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
{...props}
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -16,25 +16,32 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 { classNameFactory } from "@api/Styles";
|
||||||
import { Flex } from "@components/Flex";
|
import { Flex } from "@components/Flex";
|
||||||
import { DeleteIcon } from "@components/Icons";
|
import { CogWheel, DeleteIcon, PluginIcon } from "@components/Icons";
|
||||||
import { Link } from "@components/Link";
|
import { Link } from "@components/Link";
|
||||||
import PluginModal from "@components/PluginSettings/PluginModal";
|
import PluginModal from "@components/PluginSettings/PluginModal";
|
||||||
|
import { AddonCard } from "@components/VencordSettings/AddonCard";
|
||||||
|
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
|
||||||
import { openInviteModal } from "@utils/discord";
|
import { openInviteModal } from "@utils/discord";
|
||||||
import { Margins } from "@utils/margins";
|
import { Margins } from "@utils/margins";
|
||||||
import { classes } from "@utils/misc";
|
import { classes } from "@utils/misc";
|
||||||
import { openModal } from "@utils/modal";
|
import { openModal } from "@utils/modal";
|
||||||
import { showItemInFolder } from "@utils/native";
|
import { showItemInFolder } from "@utils/native";
|
||||||
import { useAwaiter } from "@utils/react";
|
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 { findByPropsLazy, findLazy } from "@webpack";
|
||||||
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
|
import { Button, Card, Forms, React, showToast, TabBar, TextArea, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
|
||||||
import { UserThemeHeader } from "main/themes";
|
|
||||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||||
|
import type { UserstyleHeader } from "usercss-meta";
|
||||||
|
|
||||||
import { AddonCard } from "./AddonCard";
|
import { isPluginEnabled } from "../../plugins";
|
||||||
import { SettingsTab, wrapTab } from "./shared";
|
import { UserCSSSettingsModal } from "./UserCSSModal";
|
||||||
|
|
||||||
type FileInput = ComponentType<{
|
type FileInput = ComponentType<{
|
||||||
ref: Ref<HTMLInputElement>;
|
ref: Ref<HTMLInputElement>;
|
||||||
|
@ -49,6 +56,7 @@ const TextAreaProps = findLazy(m => typeof m.textarea === "string");
|
||||||
|
|
||||||
const cl = classNameFactory("vc-settings-theme-");
|
const cl = classNameFactory("vc-settings-theme-");
|
||||||
|
|
||||||
|
|
||||||
function Validator({ link }: { link: string; }) {
|
function Validator({ link }: { link: string; }) {
|
||||||
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
|
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
|
||||||
if (res.status > 300) throw `${res.status} ${res.statusText}`;
|
if (res.status > 300) throw `${res.status} ${res.statusText}`;
|
||||||
|
@ -97,14 +105,75 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeCardProps {
|
interface OtherThemeCardProps {
|
||||||
theme: UserThemeHeader;
|
theme: UserThemeHeader;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onChange: (enabled: boolean) => void;
|
onChange: (enabled: boolean) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
|
interface UserCSSCardProps {
|
||||||
|
theme: UserstyleHeader;
|
||||||
|
enabled: boolean;
|
||||||
|
onChange: (enabled: boolean) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSettingsReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset }: 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}
|
||||||
|
disabled={missingPlugins && missingPlugins.length > 0}
|
||||||
|
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-danger" }}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
<PluginIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{theme.vars && (
|
||||||
|
<div style={{ cursor: "pointer" }} onClick={
|
||||||
|
() => openModal(modalProps =>
|
||||||
|
<UserCSSSettingsModal modalProps={modalProps} theme={theme} onSettingsReset={onSettingsReset} />)
|
||||||
|
}>
|
||||||
|
<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 (
|
return (
|
||||||
<AddonCard
|
<AddonCard
|
||||||
name={theme.name}
|
name={theme.name}
|
||||||
|
@ -151,7 +220,7 @@ function ThemesTab() {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
||||||
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
|
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);
|
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -160,7 +229,55 @@ function ThemesTab() {
|
||||||
|
|
||||||
async function refreshLocalThemes() {
|
async function refreshLocalThemes() {
|
||||||
const themes = await VencordNative.themes.getThemesList();
|
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":
|
||||||
|
case "checkbox":
|
||||||
|
normalizedValue = varInfo.default;
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
|
||||||
|
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
|
// When a local theme is enabled/disabled, update the settings
|
||||||
|
@ -269,8 +386,9 @@ function ThemesTab() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className={cl("grid")}>
|
<div className={cl("grid")}>
|
||||||
{userThemes?.map(theme => (
|
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
|
||||||
<ThemeCard
|
type === "other" ? (
|
||||||
|
<OtherThemeCard
|
||||||
key={theme.fileName}
|
key={theme.fileName}
|
||||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||||
|
@ -279,9 +397,22 @@ function ThemesTab() {
|
||||||
await VencordNative.themes.deleteTheme(theme.fileName);
|
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||||
refreshLocalThemes();
|
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();
|
||||||
|
}}
|
||||||
|
onSettingsReset={refreshLocalThemes}
|
||||||
|
theme={theme as UserstyleHeader}
|
||||||
|
/>
|
||||||
|
)))}
|
||||||
</div>
|
</div>
|
||||||
</Forms.FormSection>
|
</Forms.FormSection>
|
||||||
</>
|
</>
|
205
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
205
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2023 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Settings, useSettings } from "@api/Settings";
|
||||||
|
import { Flex } from "@components/Flex";
|
||||||
|
import { CopyIcon, PasteIcon, ResetIcon } from "@components/Icons";
|
||||||
|
import { copyWithToast } from "@utils/misc";
|
||||||
|
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||||
|
import { showToast, Text, Toasts, Tooltip } 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;
|
||||||
|
onSettingsReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
|
||||||
|
return <Tooltip text={"Copy theme settings"}>
|
||||||
|
{({ onMouseLeave, onMouseEnter }) => (
|
||||||
|
<div
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
|
||||||
|
onClick={() => {
|
||||||
|
copyWithToast(JSON.stringify(themeSettings), "Copied theme settings to clipboard.");
|
||||||
|
}}>
|
||||||
|
<CopyIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tooltip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
|
||||||
|
return <Tooltip text={"Paste theme settings"}>
|
||||||
|
{({ onMouseLeave, onMouseEnter }) => (
|
||||||
|
<div
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
|
||||||
|
onClick={async () => {
|
||||||
|
const clip = (await navigator.clipboard.read())[0];
|
||||||
|
|
||||||
|
if (!clip) return showToast("Your clipboard is empty.", Toasts.Type.FAILURE);
|
||||||
|
|
||||||
|
if (!clip.types.includes("text/plain"))
|
||||||
|
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var potentialSettings: Record<string, string> =
|
||||||
|
JSON.parse(await clip.getType("text/plain").then(b => b.text()));
|
||||||
|
} catch (e) {
|
||||||
|
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(potentialSettings)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(themeSettings, key))
|
||||||
|
themeSettings[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast("Pasted theme settings from clipboard.", Toasts.Type.SUCCESS);
|
||||||
|
}}>
|
||||||
|
<PasteIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tooltip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetButtonProps {
|
||||||
|
themeSettings: Settings["userCssVars"];
|
||||||
|
themeId: string;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetButton({ themeSettings, themeId, close, onReset }: ResetButtonProps) {
|
||||||
|
return <Tooltip text={"Reset settings to default"}>
|
||||||
|
{({ onMouseLeave, onMouseEnter }) => (
|
||||||
|
<div
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
|
||||||
|
onClick={async () => {
|
||||||
|
await close(); // close the modal first to stop rendering
|
||||||
|
delete themeSettings[themeId];
|
||||||
|
onReset();
|
||||||
|
}}>
|
||||||
|
<ResetIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tooltip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCSSSettingsModal({ modalProps, theme, onSettingsReset }: UserCSSSettingsModalProps) {
|
||||||
|
// @ts-expect-error UseSettings<> can't determine this is a valid key
|
||||||
|
const { userCssVars } = useSettings(["userCssVars"], false);
|
||||||
|
|
||||||
|
const themeVars = 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={themeVars}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "checkbox": {
|
||||||
|
controls.push(
|
||||||
|
<SettingBooleanComponent
|
||||||
|
label={varInfo.label}
|
||||||
|
name={name}
|
||||||
|
themeSettings={themeVars}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "color": {
|
||||||
|
controls.push(
|
||||||
|
<SettingColorComponent
|
||||||
|
label={varInfo.label}
|
||||||
|
name={name}
|
||||||
|
themeSettings={themeVars}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "number": {
|
||||||
|
controls.push(
|
||||||
|
<SettingNumberComponent
|
||||||
|
label={varInfo.label}
|
||||||
|
name={name}
|
||||||
|
themeSettings={themeVars}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "select": {
|
||||||
|
controls.push(
|
||||||
|
<SettingSelectComponent
|
||||||
|
label={varInfo.label}
|
||||||
|
name={name}
|
||||||
|
options={varInfo.options}
|
||||||
|
default={varInfo.default}
|
||||||
|
themeSettings={themeVars}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "range": {
|
||||||
|
controls.push(
|
||||||
|
<SettingRangeComponent
|
||||||
|
label={varInfo.label}
|
||||||
|
name={name}
|
||||||
|
default={varInfo.default}
|
||||||
|
min={varInfo.min}
|
||||||
|
max={varInfo.max}
|
||||||
|
step={varInfo.step}
|
||||||
|
themeSettings={themeVars}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps}>
|
||||||
|
<ModalHeader separator={false}>
|
||||||
|
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</Text>
|
||||||
|
<Flex style={{ gap: 4, marginRight: 4 }} className="vc-settings-usercss-ie-buttons">
|
||||||
|
<ExportButton themeSettings={themeVars} />
|
||||||
|
<ImportButton themeSettings={themeVars} />
|
||||||
|
<ResetButton themeSettings={userCssVars} themeId={theme.id} close={modalProps.onClose} onReset={onSettingsReset} />
|
||||||
|
</Flex>
|
||||||
|
<ModalCloseButton onClick={modalProps.onClose} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent>
|
||||||
|
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>
|
||||||
|
{controls}
|
||||||
|
</Flex>
|
||||||
|
</ModalContent>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2023 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Forms, Switch } from "@webpack/common";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
themeSettings: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
|
||||||
|
function handleChange(value: boolean) {
|
||||||
|
const corrected = value ? "1" : "0";
|
||||||
|
|
||||||
|
themeSettings[name] = corrected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Switch
|
||||||
|
key={name}
|
||||||
|
value={themeSettings[name] === "1"}
|
||||||
|
onChange={handleChange}
|
||||||
|
hideBorder
|
||||||
|
style={{ marginBottom: "0.5em" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Switch>
|
||||||
|
</Forms.FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* 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 } 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) {
|
||||||
|
function handleChange(value: number) {
|
||||||
|
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
|
||||||
|
|
||||||
|
themeSettings[name] = corrected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = useMemo(() => parseInt(TinyColor(themeSettings[name]).toHex(), 16), [themeSettings[name]]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Forms.FormSection>
|
||||||
|
<div className={cl("swatch-row")}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<ColorPicker
|
||||||
|
key={name}
|
||||||
|
color={normalizedValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Forms.FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2023 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Forms, TextInput } from "@webpack/common";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
themeSettings: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
|
||||||
|
function handleChange(value: string) {
|
||||||
|
themeSettings[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
pattern="-?[0-9]+"
|
||||||
|
key={name}
|
||||||
|
value={themeSettings[name]}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Forms.FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2023 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Forms, Slider, useMemo } 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) {
|
||||||
|
function handleChange(value: number) {
|
||||||
|
const corrected = value.toString();
|
||||||
|
|
||||||
|
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(themeSettings[name], 10)}
|
||||||
|
defaultValue={def}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
minValue={min}
|
||||||
|
maxValue={max}
|
||||||
|
|
||||||
|
markers={markers}
|
||||||
|
stickToMarkers={true}
|
||||||
|
/>
|
||||||
|
</Forms.FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* 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 } 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) {
|
||||||
|
function handleChange(value: string) {
|
||||||
|
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 === themeSettings[name]}
|
||||||
|
serialize={identity}
|
||||||
|
/>
|
||||||
|
</Forms.FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2023 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Forms, TextInput } from "@webpack/common";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
themeSettings: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingTextComponent({ label, name, themeSettings }: Props) {
|
||||||
|
function handleChange(value: string) {
|
||||||
|
themeSettings[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||||
|
<TextInput
|
||||||
|
key={name}
|
||||||
|
value={themeSettings[name]}
|
||||||
|
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";
|
|
@ -27,3 +27,14 @@
|
||||||
.vc-settings-theme-author::before {
|
.vc-settings-theme-author::before {
|
||||||
content: "by ";
|
content: "by ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vc-settings-usercss-ie-buttons > div {
|
||||||
|
color: var(--interactive-normal);
|
||||||
|
opacity: .5;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-settings-usercss-ie-buttons > div:hover {
|
||||||
|
color: var(--interactive-hover);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
|
@ -17,7 +17,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "./settingsStyles.css";
|
import "./settingsStyles.css";
|
||||||
import "./themesStyles.css";
|
|
||||||
|
|
||||||
import ErrorBoundary from "@components/ErrorBoundary";
|
import ErrorBoundary from "@components/ErrorBoundary";
|
||||||
import { handleComponentFailed } from "@components/handleComponentFailed";
|
import { handleComponentFailed } from "@components/handleComponentFailed";
|
||||||
|
|
|
@ -29,7 +29,6 @@ import { join, normalize } from "path";
|
||||||
|
|
||||||
import monacoHtml from "~fileContent/monacoWin.html;base64";
|
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 { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants";
|
||||||
import { makeLinksOpenExternally } from "./utils/externalLinks";
|
import { makeLinksOpenExternally } from "./utils/externalLinks";
|
||||||
|
|
||||||
|
@ -47,21 +46,11 @@ function readCss() {
|
||||||
return readFile(QUICKCSS_PATH, "utf-8").catch(() => "");
|
return readFile(QUICKCSS_PATH, "utf-8").catch(() => "");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listThemes(): Promise<UserThemeHeader[]> {
|
function listThemes(): Promise<{ fileName: string; content: string; }[]> {
|
||||||
const files = await readdir(THEMES_DIR).catch(() => []);
|
return readdir(THEMES_DIR)
|
||||||
|
.then(files =>
|
||||||
const themeInfo: UserThemeHeader[] = [];
|
Promise.all(files.map(async fileName => ({ fileName, content: await getThemeData(fileName) }))))
|
||||||
|
.catch(() => []);
|
||||||
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 getThemeData(fileName: string) {
|
function getThemeData(fileName: string) {
|
||||||
|
|
|
@ -101,7 +101,7 @@ export default definePlugin({
|
||||||
{
|
{
|
||||||
section: "VencordThemes",
|
section: "VencordThemes",
|
||||||
label: "Themes",
|
label: "Themes",
|
||||||
element: require("@components/VencordSettings/ThemesTab").default,
|
element: require("@components/ThemeSettings/ThemesTab").default,
|
||||||
className: "vc-themes"
|
className: "vc-themes"
|
||||||
},
|
},
|
||||||
!IS_UPDATER_DISABLED && {
|
!IS_UPDATER_DISABLED && {
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 { makeLazy } from "./lazy";
|
||||||
import { EXTENSION_BASE_URL } from "./web-metadata";
|
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
|
// @ts-expect-error
|
||||||
export const getStegCloak = /* #__PURE__*/ makeLazy(() => import("https://unpkg.com/stegcloak-dist@1.0.0/index.js"));
|
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,7 +17,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { addSettingsListener, Settings } from "@api/Settings";
|
import { addSettingsListener, Settings } from "@api/Settings";
|
||||||
|
import { Toasts } from "@webpack/common";
|
||||||
|
|
||||||
|
import { Logger } from "./Logger";
|
||||||
|
import { compileUsercss } from "./themes/usercss/compiler";
|
||||||
|
|
||||||
|
const logger = new Logger("QuickCSS");
|
||||||
|
|
||||||
let style: HTMLStyleElement;
|
let style: HTMLStyleElement;
|
||||||
let themesStyle: HTMLStyleElement;
|
let themesStyle: HTMLStyleElement;
|
||||||
|
@ -62,15 +67,69 @@ async function initThemes() {
|
||||||
const links: string[] = [...themeLinks];
|
const links: string[] = [...themeLinks];
|
||||||
|
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
for (const theme of enabledThemes) {
|
// make copy so we can remove themes that are missing
|
||||||
const themeData = await VencordNative.themes.getThemeData(theme);
|
for (let i = enabledThemes.length - 1; i >= 0; i--) {
|
||||||
if (!themeData) continue;
|
const theme = enabledThemes[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
var themeData = await VencordNative.themes.getThemeData(theme);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Failed to get theme data for", theme, "(has it gone missing?)", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!themeData) {
|
||||||
|
// disable the theme since it has problems
|
||||||
|
Settings.enabledThemes = enabledThemes.splice(enabledThemes.indexOf(theme), 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const blob = new Blob([themeData], { type: "text/css" });
|
const blob = new Blob([themeData], { type: "text/css" });
|
||||||
links.push(URL.createObjectURL(blob));
|
links.push(URL.createObjectURL(blob));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`);
|
for (let i = enabledThemes.length - 1; i >= 0; i--) {
|
||||||
links.push(...localThemes);
|
const theme = enabledThemes[i];
|
||||||
|
|
||||||
|
if (theme.endsWith(".user.css")) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// whilst this is unnecessary here, we're doing it to make sure the theme is valid
|
||||||
|
await VencordNative.themes.getThemeData(theme);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Failed to get theme data for", theme, "(has it gone missing?)", e);
|
||||||
|
Settings.enabledThemes = enabledThemes.splice(enabledThemes.indexOf(theme), 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
links.push(`vencord:///themes/${theme}?v=${Date.now()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IS_WEB || "armcord" in window) {
|
||||||
|
for (let i = enabledThemes.length - 1; i >= 0; i--) {
|
||||||
|
const theme = enabledThemes[i];
|
||||||
|
|
||||||
|
if (!theme.endsWith(".user.css")) continue;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Settings.enabledThemes = enabledThemes.splice(enabledThemes.indexOf(theme), 1);
|
||||||
|
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");
|
themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n");
|
||||||
|
@ -85,6 +144,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
addSettingsListener("themeLinks", initThemes);
|
addSettingsListener("themeLinks", initThemes);
|
||||||
addSettingsListener("enabledThemes", initThemes);
|
addSettingsListener("enabledThemes", initThemes);
|
||||||
|
addSettingsListener("userCssVars", initThemes, false);
|
||||||
|
|
||||||
if (!IS_WEB)
|
if (!IS_WEB)
|
||||||
VencordNative.quickCss.addThemeChangeListener(initThemes);
|
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;
|
||||||
|
};
|
115
src/utils/themes/usercss/compiler.ts
Normal file
115
src/utils/themes/usercss/compiler.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* 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) {
|
||||||
|
try {
|
||||||
|
var themeData = await VencordNative.themes.getThemeData(fileName);
|
||||||
|
} catch (e) {
|
||||||
|
UserCSSLogger.warn("Failed to get theme data for", fileName, "(has it gone missing?)", e);
|
||||||
|
}
|
||||||
|
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 = {
|
||||||
|
vencord: "true"
|
||||||
|
};
|
||||||
|
|
||||||
|
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: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
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;
|
onChange(v: string): void;
|
||||||
}>>;
|
}>>;
|
||||||
|
|
||||||
interface SelectOption {
|
export interface SelectOption {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
value: any;
|
value: any;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
Loading…
Reference in a new issue