refactor: modularize core engine and settings UI

- extracted core logic into dedicated modules
- simplified main.ts and settingsTab.ts
- improved maintainability and separation of concerns
- no functional changes intended
This commit is contained in:
YazanAmmar 2026-02-17 18:19:42 +03:00
parent c5a192157d
commit 61fa8f29ad
13 changed files with 2461 additions and 2325 deletions

View file

@ -1,7 +1,7 @@
import js from "@eslint/js"; import js from "@eslint/js";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd"; import obsidianmd from "eslint-plugin-obsidianmd";
import eslintComments from "eslint-plugin-eslint-comments"; import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
import globals from "globals"; import globals from "globals";
export default tseslint.config( export default tseslint.config(
@ -25,7 +25,7 @@ export default tseslint.config(
{ {
files: ["src/**/*.ts", "src/**/*.tsx"], files: ["src/**/*.ts", "src/**/*.tsx"],
plugins: { plugins: {
"eslint-comments": eslintComments, "@eslint-community/eslint-comments": eslintComments,
}, },
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
@ -47,8 +47,9 @@ export default tseslint.config(
"obsidianmd/hardcoded-config-path": "error", "obsidianmd/hardcoded-config-path": "error",
"obsidianmd/no-forbidden-elements": "error", "obsidianmd/no-forbidden-elements": "error",
"@typescript-eslint/await-thenable": "error", "@typescript-eslint/await-thenable": "error",
"eslint-comments/require-description": "error", "@eslint-community/eslint-comments/require-description": "error",
"eslint-comments/no-restricted-disable": [ "@eslint-community/eslint-comments/no-unused-disable": "error",
"@eslint-community/eslint-comments/no-restricted-disable": [
"error", "error",
"obsidianmd/no-forbidden-elements", "obsidianmd/no-forbidden-elements",
"obsidianmd/ui/sentence-case", "obsidianmd/ui/sentence-case",

146
package-lock.json generated
View file

@ -16,7 +16,7 @@
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/sortablejs": "^1.15.9", "@types/sortablejs": "^1.15.9",
"@typescript-eslint/utils": "^8.55.0", "@typescript-eslint/utils": "^8.56.0",
"cpy-cli": "^7.0.0", "cpy-cli": "^7.0.0",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"esbuild-sass-plugin": "^3.6.0", "esbuild-sass-plugin": "^3.6.0",
@ -27,7 +27,7 @@
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.55.0" "typescript-eslint": "^8.56.0"
} }
}, },
"node_modules/@bufbuild/protobuf": { "node_modules/@bufbuild/protobuf": {
@ -1311,17 +1311,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/type-utils": "8.56.0",
"@typescript-eslint/utils": "8.55.0", "@typescript-eslint/utils": "8.56.0",
"@typescript-eslint/visitor-keys": "8.55.0", "@typescript-eslint/visitor-keys": "8.56.0",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
@ -1334,8 +1334,8 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.55.0", "@typescript-eslint/parser": "^8.56.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
@ -1350,16 +1350,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.55.0", "@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/visitor-keys": "8.55.0", "@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1370,19 +1370,19 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/tsconfig-utils": "^8.56.0",
"@typescript-eslint/types": "^8.55.0", "@typescript-eslint/types": "^8.56.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1397,14 +1397,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.55.0", "@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.55.0" "@typescript-eslint/visitor-keys": "8.56.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1415,9 +1415,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1432,15 +1432,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.55.0", "@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.55.0", "@typescript-eslint/utils": "8.56.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
}, },
@ -1452,14 +1452,14 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1471,16 +1471,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/project-service": "8.56.0",
"@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.56.0",
"@typescript-eslint/types": "8.55.0", "@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.55.0", "@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^9.0.5", "minimatch": "^9.0.5",
"semver": "^7.7.3", "semver": "^7.7.3",
@ -1499,16 +1499,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.55.0", "@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.55.0" "@typescript-eslint/typescript-estree": "8.56.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1518,19 +1518,19 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.55.0", "@typescript-eslint/types": "8.56.0",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1541,13 +1541,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
@ -6183,16 +6183,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.55.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.55.0", "@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.55.0", "@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.55.0" "@typescript-eslint/utils": "8.56.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -6202,7 +6202,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },

View file

@ -19,20 +19,20 @@
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.0",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/sortablejs": "^1.15.9", "@types/sortablejs": "^1.15.9",
"@typescript-eslint/utils": "^8.55.0", "@typescript-eslint/utils": "^8.56.0",
"cpy-cli": "^7.0.0", "cpy-cli": "^7.0.0",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"esbuild-sass-plugin": "^3.6.0", "esbuild-sass-plugin": "^3.6.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.0",
"eslint-plugin-obsidianmd": "^0.1.9", "eslint-plugin-obsidianmd": "^0.1.9",
"globals": "^17.3.0", "globals": "^17.3.0",
"obsidian": "^1.12.0", "obsidian": "^1.12.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.55.0" "typescript-eslint": "^8.56.0"
} }
} }

View file

@ -0,0 +1,407 @@
import { Notice, requestUrl } from 'obsidian';
import { BUILT_IN_PROFILES_VARS } from '../constants';
import { t } from '../i18n/strings';
import type ThemeEngine from '../main';
import { FileConflictModal } from '../ui/modals';
import { findNextAvailablePath, maybeConvertToJpg } from '../utils';
export type MediaType = 'image' | 'video';
export type ConflictChoice = 'replace' | 'keep' | 'prompt';
export const clearBackgroundMedia = (): void => {
document.body.setCssProps({ '--cm-background-image': null });
document.body.classList.remove('cm-workspace-background-active');
document.body.classList.remove('cm-settings-background-active');
const oldVideo = document.getElementById('cm-background-video');
if (oldVideo) oldVideo.remove();
};
export const applyTransparencyToVars = async (plugin: ThemeEngine): Promise<boolean> => {
const profile = plugin.settings.profiles?.[plugin.settings.activeProfile];
if (!profile) return false;
const activeProfileName = plugin.settings.activeProfile;
const profileOriginalVars =
BUILT_IN_PROFILES_VARS[activeProfileName as keyof typeof BUILT_IN_PROFILES_VARS];
const baseVars = profileOriginalVars || BUILT_IN_PROFILES_VARS.Default;
const varsToMakeTransparent = [
'--background-primary',
'--background-secondary',
'--background-modifier-border',
'--titlebar-background-focused',
'--background-modifier-hover',
];
let settingsChanged = false;
for (const varName of varsToMakeTransparent) {
if (profile.vars[varName] !== 'transparent') {
const defaultValue = baseVars[varName as keyof typeof baseVars];
if (!profile.vars[varName] || profile.vars[varName] === defaultValue) {
profile.vars[varName] = 'transparent';
settingsChanged = true;
if (plugin.settings.colorUpdateFPS > 0) {
plugin.pendingVarUpdates[varName] = 'transparent';
}
}
}
}
if (settingsChanged) {
await plugin.saveData(plugin.settings);
if (plugin.settingTabInstance && plugin.settingTabInstance.containerEl.offsetHeight > 0) {
plugin.settingTabInstance.display();
}
}
return settingsChanged;
};
export const restoreDefaultBackgroundVars = async (plugin: ThemeEngine): Promise<boolean> => {
const profile = plugin.settings.profiles?.[plugin.settings.activeProfile];
if (!profile) return false;
const activeProfileName = plugin.settings.activeProfile;
const profileOriginalVars =
BUILT_IN_PROFILES_VARS[activeProfileName as keyof typeof BUILT_IN_PROFILES_VARS];
const baseVars = profileOriginalVars || BUILT_IN_PROFILES_VARS.Default;
const varsToRestore = [
'--background-primary',
'--background-secondary',
'--background-modifier-border',
'--titlebar-background-focused',
'--background-modifier-hover',
];
let settingsRestored = false;
for (const varName of varsToRestore) {
if (profile.vars[varName] === 'transparent') {
const defaultValue = baseVars[varName as keyof typeof baseVars];
if (defaultValue) {
profile.vars[varName] = defaultValue;
settingsRestored = true;
if (plugin.settings.colorUpdateFPS > 0) {
plugin.pendingVarUpdates[varName] = defaultValue;
}
}
}
}
if (settingsRestored) {
await plugin.saveData(plugin.settings);
if (plugin.settingTabInstance && plugin.settingTabInstance.containerEl.offsetHeight > 0) {
plugin.settingTabInstance.display();
}
if (plugin.settings.colorUpdateFPS === 0) {
plugin.applyPendingNow();
}
return true;
}
return false;
};
export const applyBackgroundMedia = async (plugin: ThemeEngine): Promise<void> => {
const profile = plugin.settings.profiles?.[plugin.settings.activeProfile];
clearBackgroundMedia();
if (!profile || profile.backgroundEnabled === false || !profile.backgroundPath) {
if (profile && profile.backgroundEnabled === false) {
await restoreDefaultBackgroundVars(plugin);
}
return;
}
await applyTransparencyToVars(plugin);
const path = profile.backgroundPath;
const type = profile.backgroundType;
if (type === 'image') {
const imageUrl = plugin.app.vault.adapter.getResourcePath(path);
document.body.setCssProps({
'--cm-background-image': `url("${imageUrl}")`,
});
document.body.classList.add('cm-workspace-background-active');
} else if (type === 'video') {
const videoUrl = plugin.app.vault.adapter.getResourcePath(path);
let videoEl = document.querySelector<HTMLVideoElement>('#cm-background-video');
if (!videoEl) {
videoEl = document.createElement('video');
videoEl.id = 'cm-background-video';
document.body.appendChild(videoEl);
}
videoEl.src = videoUrl;
videoEl.autoplay = true;
videoEl.loop = true;
videoEl.muted = profile.videoMuted !== false;
videoEl.playsInline = true;
videoEl.setCssProps({
opacity: (profile.videoOpacity || 0.5).toString(),
});
videoEl.load();
document.body.classList.add('cm-workspace-background-active');
}
};
export const ensureBackgroundsFolderExists = async (plugin: ThemeEngine): Promise<void> => {
const backgroundsPath = `${plugin.app.vault.configDir}/backgrounds`;
try {
if (!(await plugin.app.vault.adapter.exists(backgroundsPath))) {
await plugin.app.vault.adapter.mkdir(backgroundsPath);
console.debug(`Theme Engine: Created global backgrounds folder at ${backgroundsPath}`);
}
} catch (error) {
console.error('Theme Engine: Failed to create backgrounds folder on startup.', error);
}
};
export const removeBackgroundMediaByPath = async (
plugin: ThemeEngine,
pathToDelete: string,
): Promise<void> => {
if (!pathToDelete) return;
const varsToRestore = [
'--background-primary',
'--background-secondary',
'--background-modifier-border',
'--titlebar-background-focused',
'--background-modifier-hover',
];
let settingsChanged = false;
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (profile.backgroundPath === pathToDelete) {
profile.backgroundPath = '';
profile.backgroundType = undefined;
profile.backgroundEnabled = false;
const profileOriginalVars =
BUILT_IN_PROFILES_VARS[profileName as keyof typeof BUILT_IN_PROFILES_VARS] ||
BUILT_IN_PROFILES_VARS.Default;
for (const varName of varsToRestore) {
if (profile.vars[varName] === 'transparent') {
const defaultValue = profileOriginalVars[varName as keyof typeof profileOriginalVars];
if (defaultValue) {
profile.vars[varName] = defaultValue;
} else {
delete profile.vars[varName];
}
}
}
settingsChanged = true;
}
}
try {
if (await plugin.app.vault.adapter.exists(pathToDelete)) {
await plugin.app.vault.adapter.remove(pathToDelete);
}
} catch (error) {
console.warn(`Theme Engine: Could not delete background file '${pathToDelete}'.`, error);
}
if (settingsChanged) {
await plugin.saveSettings();
const activeProfile = plugin.settings.profiles[plugin.settings.activeProfile];
if (activeProfile.backgroundPath === '') {
for (const varName of varsToRestore) {
const value = activeProfile.vars[varName];
if (value) plugin.pendingVarUpdates[varName] = value;
}
plugin.applyPendingNow();
}
}
if (plugin.settingTabInstance) {
plugin.settingTabInstance.display();
}
};
export const setBackgroundMedia = async (
plugin: ThemeEngine,
arrayBuffer: ArrayBuffer,
fileName: string,
conflictChoice: ConflictChoice = 'prompt',
): Promise<void> => {
const activeProfile = plugin.settings.profiles?.[plugin.settings.activeProfile];
if (!activeProfile) return;
const { arrayBuffer: finalArrayBuffer, fileName: finalFileName } = await maybeConvertToJpg(
activeProfile,
arrayBuffer,
fileName,
);
const fileExt = finalFileName.split('.').pop()?.toLowerCase();
const mediaType: MediaType = fileExt === 'mp4' || fileExt === 'webm' ? 'video' : 'image';
const backgroundsPath = `${plugin.app.vault.configDir}/backgrounds`;
let targetPath = `${backgroundsPath}/${finalFileName}`;
try {
if (!(await plugin.app.vault.adapter.exists(backgroundsPath))) {
await plugin.app.vault.adapter.mkdir(backgroundsPath);
}
const fileExists = await plugin.app.vault.adapter.exists(targetPath);
if (fileExists && conflictChoice === 'prompt') {
new FileConflictModal(plugin.app, plugin, finalArrayBuffer, finalFileName, (choice) => {
void setBackgroundMedia(plugin, finalArrayBuffer, finalFileName, choice).catch((error) => {
console.error('Failed to set background media:', error);
});
}).open();
return;
}
if (fileExists && conflictChoice === 'keep') {
targetPath = await findNextAvailablePath(plugin.app.vault.adapter, targetPath);
}
if (fileExists && conflictChoice === 'replace') {
await plugin.app.vault.adapter.remove(targetPath);
}
await plugin.app.vault.createBinary(targetPath, finalArrayBuffer);
const oldImagePath = activeProfile.backgroundPath;
activeProfile.backgroundPath = targetPath;
activeProfile.backgroundType = mediaType;
await plugin.saveSettings();
new Notice(t('notices.bgSet'));
if (oldImagePath && oldImagePath !== targetPath && conflictChoice === 'replace') {
if (await plugin.app.vault.adapter.exists(oldImagePath)) {
await plugin.app.vault.adapter.remove(oldImagePath);
}
}
if (plugin.settingTabInstance) {
plugin.settingTabInstance.display();
}
} catch (error) {
new Notice(t('notices.backgroundLoadError'));
console.error('Theme Engine: Error setting background media:', error);
}
};
export const setBackgroundMediaFromUrl = async (
plugin: ThemeEngine,
url: string,
): Promise<void> => {
if (!url) {
new Notice(t('notices.backgroundUrlLoadError'));
return;
}
try {
const response = await requestUrl({ url });
const arrayBuffer = response.arrayBuffer;
let fileName = url.split('/').pop();
if (fileName) {
fileName = fileName.split('?')[0];
}
if (!fileName || fileName.indexOf('.') === -1 || fileName.length > 50) {
const extension = response.headers['content-type']?.split('/')[1] || 'png';
fileName = `image-${Date.now()}.${extension}`;
}
await setBackgroundMedia(plugin, arrayBuffer, fileName, 'prompt');
} catch (error) {
new Notice(t('notices.backgroundUrlLoadError'));
console.error('Theme Engine: Error fetching image from URL:', error);
}
};
export const selectBackgroundMedia = async (
plugin: ThemeEngine,
newPath: string,
mediaType: MediaType,
): Promise<void> => {
const activeProfile = plugin.settings.profiles?.[plugin.settings.activeProfile];
if (!activeProfile) return;
activeProfile.backgroundPath = newPath;
activeProfile.backgroundType = mediaType;
activeProfile.backgroundEnabled = true;
await plugin.saveSettings();
new Notice(t('notices.bgSet'));
};
export const renameBackgroundMedia = async (
plugin: ThemeEngine,
oldPath: string,
newFullName: string,
): Promise<string | false> => {
const adapter = plugin.app.vault.adapter;
if (!newFullName || newFullName.includes('/') || newFullName.includes('\\')) {
new Notice(t('notices.invalidFilename'));
return false;
}
const pathParts = oldPath.split('/');
const originalFileName = pathParts.pop() || '';
const folderPath = pathParts.join('/');
const newPath = `${folderPath}/${newFullName}`;
const oldExtMatch = originalFileName.match(/\.([a-zA-Z0-9]+)$/);
const oldExt = oldExtMatch ? oldExtMatch[0] : '';
if (oldExt && !newFullName.toLowerCase().endsWith(oldExt.toLowerCase())) {
console.warn(
`Theme Engine: Rename blocked. Attempted to change extension from "${oldExt}" in "${newFullName}".`,
);
new Notice(t('notices.invalidFilename') + ' (Extension mismatch)');
return false;
}
if (await adapter.exists(newPath)) {
if (oldPath.toLowerCase() !== newPath.toLowerCase()) {
new Notice(t('notices.filenameExists', newFullName));
return false;
}
}
try {
await adapter.rename(oldPath, newPath);
let settingsChanged = false;
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (profile && profile.backgroundPath === oldPath) {
profile.backgroundPath = newPath;
settingsChanged = true;
}
}
if (settingsChanged) {
await plugin.saveSettings();
}
new Notice(t('notices.renameSuccess', newFullName));
return newPath;
} catch (error) {
new Notice(t('notices.renameError'));
console.error('Theme Engine: Error renaming background image:', error);
return false;
}
};

242
src/core/migrations.ts Normal file
View file

@ -0,0 +1,242 @@
import { PLUGIN_COMMAND_SUFFIXES } from '../commands';
import type ThemeEngine from '../main';
type HotkeyEntry = { modifiers: string[]; key: string };
export const LEGACY_PLUGIN_IDS = [
'obsidian-theme-engine',
'color-master',
'obsidian-color-master',
] as const;
const LEGACY_COMMAND_PLUGIN_IDS = [
'obsidian-theme-engine',
'color-master',
'obsidian-color-master',
] as const;
export const CURRENT_PLUGIN_ID = 'theme-engine';
const isObjectRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};
const normalizeHotkeyArray = (value: unknown): HotkeyEntry[] => {
const normalized: HotkeyEntry[] = [];
const seen = new Set<string>();
if (!Array.isArray(value)) {
return normalized;
}
for (const item of value) {
if (!isObjectRecord(item)) continue;
const keyRaw = item.key;
if (typeof keyRaw !== 'string' || keyRaw.trim() === '') continue;
const modifiersRaw = item.modifiers;
const modifiers = Array.isArray(modifiersRaw)
? modifiersRaw.filter((modifier): modifier is string => typeof modifier === 'string')
: [];
const entry = { modifiers, key: keyRaw };
const signature = JSON.stringify(entry);
if (seen.has(signature)) continue;
seen.add(signature);
normalized.push(entry);
}
return normalized;
};
const mergeHotkeyArrays = (currentValue: unknown, legacyValue: unknown): HotkeyEntry[] => {
const merged = [...normalizeHotkeyArray(currentValue), ...normalizeHotkeyArray(legacyValue)];
const deduped: HotkeyEntry[] = [];
const seen = new Set<string>();
for (const entry of merged) {
const signature = JSON.stringify(entry);
if (seen.has(signature)) continue;
seen.add(signature);
deduped.push(entry);
}
return deduped;
};
const writeJsonWithBackup = async (
plugin: ThemeEngine,
path: string,
backupPath: string,
data: unknown,
): Promise<void> => {
const adapter = plugin.app.vault.adapter;
if (!(await adapter.exists(backupPath)) && (await adapter.exists(path))) {
const currentRaw = await adapter.read(path);
await adapter.write(backupPath, currentRaw);
}
await adapter.write(path, JSON.stringify(data, null, 2));
};
export const migrateLegacyPluginDataIfNeeded = async (
plugin: ThemeEngine,
): Promise<{
migrated: boolean;
sourcePath?: string;
}> => {
let currentData: unknown = null;
try {
currentData = await plugin.loadData();
} catch (error) {
console.warn('Theme Engine: Failed to read current plugin data before migration.', error);
}
if (isObjectRecord(currentData) && Object.keys(currentData).length > 0) {
return { migrated: false };
}
const adapter = plugin.app.vault.adapter;
const pluginsDir = `${plugin.app.vault.configDir}/plugins`;
for (const legacyPluginId of LEGACY_PLUGIN_IDS) {
const sourcePath = `${pluginsDir}/${legacyPluginId}/data.json`;
try {
if (!(await adapter.exists(sourcePath))) continue;
const raw = await adapter.read(sourcePath);
const parsed = JSON.parse(raw) as unknown;
if (!isObjectRecord(parsed)) {
console.warn(
`Theme Engine: Legacy data at "${sourcePath}" is not an object. Skipping migration source.`,
);
continue;
}
const migratedSettings: Record<string, unknown> = { ...parsed };
migratedSettings.idMigration = {
from: legacyPluginId,
to: CURRENT_PLUGIN_ID,
at: new Date().toISOString(),
sourcePath,
};
await plugin.saveData(migratedSettings);
console.debug(`Theme Engine: Migrated settings data from "${sourcePath}".`);
return { migrated: true, sourcePath };
} catch (error) {
console.warn(`Theme Engine: Failed to migrate data from "${sourcePath}".`, error);
}
}
return { migrated: false };
};
export const migrateLegacyHotkeysIfNeeded = async (plugin: ThemeEngine): Promise<boolean> => {
const adapter = plugin.app.vault.adapter;
const configDir = plugin.app.vault.configDir;
const hotkeysPath = `${configDir}/hotkeys.json`;
const backupPath = `${configDir}/hotkeys.${CURRENT_PLUGIN_ID}-id-migration.bak.json`;
try {
if (!(await adapter.exists(hotkeysPath))) return false;
const raw = await adapter.read(hotkeysPath);
const parsed = JSON.parse(raw) as unknown;
if (!isObjectRecord(parsed)) return false;
const hotkeyMap = parsed;
let changed = false;
for (const suffix of PLUGIN_COMMAND_SUFFIXES) {
const newCommandId = `${CURRENT_PLUGIN_ID}:${suffix}`;
const currentValue = hotkeyMap[newCommandId];
const normalizedCurrent = normalizeHotkeyArray(currentValue);
let merged: HotkeyEntry[] = normalizedCurrent;
for (const legacyCommandPluginId of LEGACY_COMMAND_PLUGIN_IDS) {
const oldCommandId = `${legacyCommandPluginId}:${suffix}`;
const legacyValue = hotkeyMap[oldCommandId];
if (typeof legacyValue === 'undefined') continue;
merged = mergeHotkeyArrays(merged, legacyValue);
delete hotkeyMap[oldCommandId];
changed = true;
}
if (JSON.stringify(normalizedCurrent) !== JSON.stringify(merged)) {
hotkeyMap[newCommandId] = merged;
changed = true;
}
}
if (!changed) return false;
await writeJsonWithBackup(plugin, hotkeysPath, backupPath, hotkeyMap);
console.debug(
`Theme Engine: Migrated hotkeys from legacy namespaces to "${CURRENT_PLUGIN_ID}:*".`,
);
return true;
} catch (error) {
console.warn('Theme Engine: Failed to migrate hotkeys.', error);
return false;
}
};
export const migrateCommunityPluginListIfNeeded = async (plugin: ThemeEngine): Promise<boolean> => {
const adapter = plugin.app.vault.adapter;
const configDir = plugin.app.vault.configDir;
const listPath = `${configDir}/community-plugins.json`;
const backupPath = `${configDir}/community-plugins.${CURRENT_PLUGIN_ID}-id-migration.bak.json`;
try {
if (!(await adapter.exists(listPath))) return false;
const raw = await adapter.read(listPath);
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return false;
const entries = parsed.filter((entry): entry is string => typeof entry === 'string');
let changed = entries.length !== parsed.length;
const updatedEntries: string[] = [];
let hasCurrentId = false;
for (const entry of entries) {
if (entry === CURRENT_PLUGIN_ID) {
if (!hasCurrentId) {
updatedEntries.push(entry);
hasCurrentId = true;
} else {
changed = true;
}
continue;
}
if (LEGACY_PLUGIN_IDS.some((legacyId) => legacyId === entry)) {
if (!hasCurrentId) {
updatedEntries.push(CURRENT_PLUGIN_ID);
hasCurrentId = true;
}
changed = true;
continue;
}
updatedEntries.push(entry);
}
if (!changed) return false;
await writeJsonWithBackup(plugin, listPath, backupPath, updatedEntries);
console.debug(
`Theme Engine: Migrated community plugin list entry from legacy IDs to "${CURRENT_PLUGIN_ID}".`,
);
return true;
} catch (error) {
console.warn('Theme Engine: Failed to migrate community plugin list.', error);
return false;
}
};

232
src/core/notice-engine.ts Normal file
View file

@ -0,0 +1,232 @@
import type ThemeEngine from '../main';
import type { NoticeRule } from '../types';
const matchesRule = (noticeText: string, rule: NoticeRule): boolean => {
if (!rule.keywords?.trim()) return false;
const keywords = rule.keywords.toLowerCase();
let match = false;
if (rule.isRegex) {
try {
if (new RegExp(keywords, 'i').test(noticeText)) match = true;
} catch {
// ignore invalid regex
}
} else {
const keywordArray = keywords
.split(',')
.map((k: string) => k.trim())
.filter(Boolean);
if (keywordArray.length > 0) {
const escaped = keywordArray.map((k: string) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
if (new RegExp(`\\b(${escaped.join('|')})\\b`, 'i').test(noticeText)) {
match = true;
}
}
}
return match;
};
export const processNotice = (plugin: ThemeEngine, el: HTMLElement): void => {
if (!el || !el.classList || el.dataset.cmProcessed === 'true') return;
let noticeText = (el.textContent || '').toLowerCase();
const testKeywordEl = el.querySelector<HTMLElement>('.cm-test-keyword');
if (testKeywordEl) {
noticeText = (testKeywordEl.textContent || '').toLowerCase();
}
el.dataset.cmProcessed = 'true';
try {
const settings = plugin.settings;
const activeProfile = settings.profiles[settings.activeProfile];
if (!activeProfile) return;
const liveRules = plugin.liveNoticeRules;
const liveRuleType = plugin.liveNoticeRuleType;
const bgRules: NoticeRule[] =
liveRuleType === 'background' && liveRules
? liveRules
: activeProfile?.noticeRules?.background || [];
let finalBgColor = activeProfile.vars['--cm-notice-bg-default'];
for (const rule of bgRules) {
if (matchesRule(noticeText, rule)) {
finalBgColor = rule.color;
break;
}
}
if (finalBgColor) {
el.dataset.cmNoticeBg = finalBgColor;
}
const textRules: NoticeRule[] =
liveRuleType === 'text' && liveRules ? liveRules : activeProfile?.noticeRules?.text || [];
let finalTextColor = activeProfile.vars['--cm-notice-text-default'];
const highlightRules: NoticeRule[] = [];
const fullColorRules: NoticeRule[] = [];
for (const rule of textRules) {
if (rule.highlightOnly) {
highlightRules.push(rule);
} else {
fullColorRules.push(rule);
}
}
for (const rule of fullColorRules) {
if (matchesRule(noticeText, rule)) {
finalTextColor = rule.color;
break;
}
}
if (finalTextColor) {
el.dataset.cmNoticeText = finalTextColor;
}
if (highlightRules.length > 0) {
const textNodes: Node[] = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
return node.parentElement?.classList.contains('cm-keyword-highlight')
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
});
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
const nodeContent = node.textContent;
if (!nodeContent?.trim()) continue;
const parent = node.parentElement;
if (!parent) continue;
const allMatches: { start: number; end: number; color: string }[] = [];
for (const rule of highlightRules) {
if (!rule.keywords?.trim()) continue;
const color = rule.color;
if (rule.isRegex) {
try {
const regex = new RegExp(rule.keywords, 'gi');
let match;
while ((match = regex.exec(nodeContent)) !== null) {
if (match[0].length === 0) break;
allMatches.push({
start: match.index,
end: match.index + match[0].length,
color,
});
}
} catch (error) {
console.warn('Theme Engine: Invalid Regex in notice rule', error);
}
} else {
const keywords = rule.keywords
.split(',')
.map((k: string) => k.trim())
.filter(Boolean);
if (keywords.length > 0) {
const escaped = keywords.map((k: string) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const ruleRegex = new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi');
let match;
while ((match = ruleRegex.exec(nodeContent)) !== null) {
allMatches.push({
start: match.index,
end: match.index + match[0].length,
color,
});
}
}
}
}
if (allMatches.length === 0) continue;
allMatches.sort((a, b) => a.start - b.start);
const uniqueMatches = [];
let currentEnd = -1;
for (const match of allMatches) {
if (match.start >= currentEnd) {
uniqueMatches.push(match);
currentEnd = match.end;
}
}
if (uniqueMatches.length === 0) continue;
const fragments = new DocumentFragment();
let lastIndex = 0;
for (const match of uniqueMatches) {
if (match.start > lastIndex) {
fragments.appendChild(
document.createTextNode(nodeContent.substring(lastIndex, match.start)),
);
}
const span = document.createElement('span');
span.className = 'cm-keyword-highlight';
span.setCssProps({ color: match.color });
span.textContent = nodeContent.substring(match.start, match.end);
fragments.appendChild(span);
lastIndex = match.end;
}
if (lastIndex < nodeContent.length) {
fragments.appendChild(document.createTextNode(nodeContent.substring(lastIndex)));
}
parent.replaceChild(fragments, node);
}
}
updateNoticeStyles(plugin);
} catch (error) {
console.warn('Theme Engine: processNotice failed', error);
}
};
export const updateNoticeStyles = (plugin: ThemeEngine): void => {
const styleId = 'cm-dynamic-notice-styles';
const notices = document.querySelectorAll<HTMLElement>(
'[data-cm-notice-bg], [data-cm-notice-text]',
);
if (notices.length === 0) {
plugin.clearRuntimeStyle(styleId);
return;
}
const cssRules: string[] = [];
notices.forEach((notice, i) => {
const uniqueId = notice.dataset.cmNoticeId || `cm-notice-${i}`;
notice.dataset.cmNoticeId = uniqueId;
const bgColor = notice.dataset.cmNoticeBg;
const textColor = notice.dataset.cmNoticeText;
let rule = `[data-cm-notice-id="${uniqueId}"] {`;
if (bgColor) {
rule += ` background-color: ${bgColor} !important;`;
}
if (textColor) {
rule += ` color: ${textColor} !important;`;
}
rule += ' }';
cssRules.push(rule);
});
plugin.setRuntimeStyle(styleId, cssRules.join('\n'));
};

133
src/core/profile-state.ts Normal file
View file

@ -0,0 +1,133 @@
import { Notice } from 'obsidian';
import { t } from '../i18n/strings';
import type ThemeEngine from '../main';
import type { NoticeRule, Profile, Snippet } from '../types';
type LegacyPinnedSnapshot = {
vars?: Record<string, string>;
customCss?: string;
snippets?: Snippet[];
noticeRules?: {
text: NoticeRule[];
background: NoticeRule[];
};
};
export const pinProfileSnapshot = async (
plugin: ThemeEngine,
profileName: string,
): Promise<void> => {
const targetProfile = profileName || plugin.settings.activeProfile;
plugin.settings.pinnedSnapshots = plugin.settings.pinnedSnapshots || {};
const profile = plugin.settings.profiles?.[targetProfile];
if (!profile) {
new Notice(t('notices.profileNotFound'));
return;
}
const snapshotProfile: Profile = JSON.parse(
JSON.stringify({
...profile,
vars: profile.vars || {},
themeType: profile.themeType || 'auto',
snippets: Array.isArray(profile.snippets) ? profile.snippets : [],
customCss: profile.customCss || '',
noticeRules: profile.noticeRules || { text: [], background: [] },
}),
);
plugin.settings.pinnedSnapshots[targetProfile] = {
pinnedAt: new Date().toISOString(),
profile: snapshotProfile,
};
await plugin.saveSettings();
};
export const resetProfileToPinned = async (
plugin: ThemeEngine,
profileName: string,
): Promise<void> => {
const targetProfile = profileName || plugin.settings.activeProfile;
const snap = plugin.settings.pinnedSnapshots?.[targetProfile];
if (!snap) {
new Notice(t('notices.noPinnedSnapshot'));
return;
}
const activeProfile = plugin.settings.profiles[targetProfile];
if (!activeProfile) {
new Notice(t('notices.profileNotFound'));
return;
}
const legacySnap = snap as LegacyPinnedSnapshot;
const pinnedProfile: Profile | null = snap.profile
? JSON.parse(JSON.stringify(snap.profile))
: legacySnap.vars
? {
vars: JSON.parse(JSON.stringify(legacySnap.vars)),
themeType: activeProfile.themeType || 'auto',
snippets: Array.isArray(legacySnap.snippets)
? JSON.parse(JSON.stringify(legacySnap.snippets))
: [],
customCss: legacySnap.customCss || '',
noticeRules: legacySnap.noticeRules
? JSON.parse(JSON.stringify(legacySnap.noticeRules))
: { text: [], background: [] },
}
: null;
if (!pinnedProfile || !pinnedProfile.vars) {
new Notice(t('notices.noPinnedSnapshot'));
return;
}
activeProfile.vars = pinnedProfile.vars;
activeProfile.customCss = pinnedProfile.customCss || '';
activeProfile.snippets = Array.isArray(pinnedProfile.snippets) ? pinnedProfile.snippets : [];
activeProfile.noticeRules = pinnedProfile.noticeRules || { text: [], background: [] };
Object.keys(pinnedProfile.vars).forEach((key) => {
plugin.pendingVarUpdates[key] = pinnedProfile.vars[key];
});
await plugin.saveSettings();
plugin.applyPendingNow();
};
export const pushCssHistory = (plugin: ThemeEngine, id: string, content: string): void => {
if (!plugin.cssHistory[id]) {
plugin.cssHistory[id] = { undoStack: [], redoStack: [] };
}
const history = plugin.cssHistory[id];
const lastState = history.undoStack[history.undoStack.length - 1];
if (lastState !== content) {
history.undoStack.push(content);
history.redoStack = [];
}
};
export const undoCssHistory = (plugin: ThemeEngine, id: string): string | null => {
const history = plugin.cssHistory[id];
if (history && history.undoStack.length > 1) {
const currentState = history.undoStack.pop();
if (typeof currentState === 'string') {
history.redoStack.push(currentState);
}
return history.undoStack[history.undoStack.length - 1];
}
return null;
};
export const redoCssHistory = (plugin: ThemeEngine, id: string): string | null => {
const history = plugin.cssHistory[id];
if (history && history.redoStack.length > 0) {
const nextState = history.redoStack.pop();
if (typeof nextState === 'string') {
history.undoStack.push(nextState);
return nextState;
}
}
return null;
};

270
src/core/settings-store.ts Normal file
View file

@ -0,0 +1,270 @@
import { ButtonComponent, Notice } from 'obsidian';
import { DEFAULT_SETTINGS, DEFAULT_VARS } from '../constants';
import { t } from '../i18n/strings';
import type ThemeEngine from '../main';
import type { PluginSettings } from '../types';
import { convertColorToHex, flattenVars } from '../utils';
type AppWithCommands = {
commands: {
executeCommandById: (commandId: string) => void;
};
};
type LegacySnippetData = {
css?: string;
enabled?: boolean;
};
export const loadSettings = async (plugin: ThemeEngine): Promise<void> => {
plugin.settings = Object.assign({}, DEFAULT_SETTINGS, await plugin.loadData());
let migrationNeeded = false;
if (plugin.settings.noticeRules && Object.keys(plugin.settings.profiles || {}).length > 0) {
console.debug('Theme Engine: Detected old global notice rules. Starting migration...');
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (!profile.noticeRules) {
profile.noticeRules = JSON.parse(JSON.stringify(plugin.settings.noticeRules));
}
}
delete plugin.settings.noticeRules;
migrationNeeded = true;
}
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (!profile.noticeRules) {
profile.noticeRules = { text: [], background: [] };
migrationNeeded = true;
}
}
if (migrationNeeded) {
console.debug('Theme Engine: Notice rules migration complete. Saving new settings structure.');
await plugin.saveData(plugin.settings);
}
if (!plugin.settings.installDate) {
plugin.settings.installDate = new Date().toISOString();
const isDarkMode = document.body.classList.contains('theme-dark');
if (isDarkMode) {
plugin.settings.activeProfile = 'Default';
} else {
plugin.settings.activeProfile = 'Citrus Zest';
}
await plugin.saveData(plugin.settings);
}
if (!plugin.settings.pinnedSnapshots) {
plugin.settings.pinnedSnapshots = {};
}
let snippetsMigrationNeeded = false;
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (
profile &&
profile.snippets &&
!Array.isArray(profile.snippets) &&
typeof profile.snippets === 'object'
) {
snippetsMigrationNeeded = true;
const legacySnippets = profile.snippets as unknown as Record<string, LegacySnippetData>;
const snippetsArray = Object.entries(legacySnippets).map(([name, data]) => ({
id: `snippet-${Date.now()}-${Math.random()}`,
name,
css: data.css || '',
enabled: data.enabled !== false,
}));
profile.snippets = snippetsArray;
}
}
if (snippetsMigrationNeeded) {
console.debug(
'Theme Engine: The clipping data structure is being migrated to the new format (array).',
);
await plugin.saveData(plugin.settings);
}
let profileMigrationNeeded = false;
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (profile && profile.backgroundPath && typeof profile.backgroundEnabled === 'undefined') {
profile.backgroundEnabled = true;
profileMigrationNeeded = true;
} else if (profile && !profile.backgroundPath && profile.backgroundEnabled === true) {
profile.backgroundEnabled = false;
profileMigrationNeeded = true;
}
}
if (profileMigrationNeeded) {
console.debug('Theme Engine: Setting default backgroundEnabled status for profiles.');
await plugin.saveData(plugin.settings);
}
};
export const saveSettings = async (plugin: ThemeEngine): Promise<void> => {
await plugin.saveData(plugin.settings);
await plugin.applyStyles();
await plugin.refreshOpenGraphViews();
plugin.app.workspace.trigger('css-change');
};
export const resetPluginData = async (
plugin: ThemeEngine,
options: {
deleteProfiles: boolean;
deleteSnippets: boolean;
deleteSettings: boolean;
deleteBackgrounds: boolean;
deleteLanguages?: boolean;
},
): Promise<void> => {
const oldInstallDate = plugin.settings.installDate;
const newSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as PluginSettings;
if (!options.deleteSettings) {
newSettings.language = plugin.settings.language;
newSettings.useRtlLayout = plugin.settings.useRtlLayout;
newSettings.overrideIconizeColors = plugin.settings.overrideIconizeColors;
newSettings.cleanupInterval = plugin.settings.cleanupInterval;
newSettings.colorUpdateFPS = plugin.settings.colorUpdateFPS;
newSettings.lastSearchQuery = plugin.settings.lastSearchQuery;
newSettings.lastScrollPosition = plugin.settings.lastScrollPosition;
newSettings.advancedResetOptions = plugin.settings.advancedResetOptions;
}
if (!options.deleteProfiles) {
newSettings.profiles = plugin.settings.profiles;
newSettings.activeProfile = plugin.settings.activeProfile;
newSettings.pinnedSnapshots = plugin.settings.pinnedSnapshots;
}
if (!options.deleteSnippets) {
newSettings.globalSnippets = plugin.settings.globalSnippets;
}
if (!options.deleteLanguages) {
newSettings.customLanguages = plugin.settings.customLanguages;
}
if (options.deleteSnippets && !options.deleteProfiles) {
for (const profileName in newSettings.profiles) {
if (newSettings.profiles[profileName]) {
newSettings.profiles[profileName].snippets = [];
}
}
}
newSettings.installDate = oldInstallDate;
if (options.deleteSettings) {
newSettings.advancedResetOptions = plugin.settings.advancedResetOptions;
newSettings.language = 'auto';
}
plugin.settings = newSettings;
await plugin.saveData(plugin.settings);
console.debug('Theme Engine: Selective data reset complete.', options);
if (options.deleteBackgrounds) {
const backgroundsPath = `${plugin.app.vault.configDir}/backgrounds`;
try {
if (await plugin.app.vault.adapter.exists(backgroundsPath)) {
console.debug('Theme Engine: Deleting backgrounds folder...');
await plugin.app.vault.adapter.rmdir(backgroundsPath, true);
}
if (!options.deleteProfiles) {
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (profile) {
profile.backgroundPath = undefined;
profile.backgroundType = undefined;
}
}
await plugin.saveData(plugin.settings);
}
} catch (folderError) {
const errorMessage = folderError instanceof Error ? folderError.message : String(folderError);
console.error(`Theme Engine: Error deleting backgrounds: ${errorMessage}`);
new Notice(t('notices.deleteBackgroundsError', errorMessage));
}
}
const notice = new Notice(t('notices.resetSuccess'), 15000);
const buttonContainer = notice.messageEl.createDiv({
cls: 'modal-button-container',
});
new ButtonComponent(buttonContainer)
.setButtonText(t('buttons.reload'))
.setCta()
.onClick(() => {
const appWithCommands = plugin.app as typeof plugin.app & AppWithCommands;
appWithCommands.commands.executeCommandById('app:reload');
});
};
export const captureCurrentComputedVars = async (
plugin: ThemeEngine,
): Promise<Record<string, string>> => {
console.debug('Theme Engine: Capturing current computed styles...');
plugin.clearStyles();
await new Promise((resolve) => requestAnimationFrame(resolve));
const capturedVars: Record<string, string> = {};
const flatDefaultVars = flattenVars(DEFAULT_VARS);
const allVarKeys = Object.keys(flatDefaultVars);
const bodyStyles = getComputedStyle(document.body);
const isColorRegex =
/^(#|rgb|hsl|transparent|var\(--)|(white|black|red|blue|green|yellow|orange|purple|cyan|magenta)$/i;
for (const varName of allVarKeys) {
const computedValue = bodyStyles.getPropertyValue(varName).trim();
const defaultValue = flatDefaultVars[varName] || '';
if (computedValue) {
if (isColorRegex.test(defaultValue)) {
try {
capturedVars[varName] = convertColorToHex(computedValue);
} catch (error) {
console.warn(`Failed to convert color ${varName}: ${computedValue}`, error);
capturedVars[varName] = computedValue;
}
} else {
capturedVars[varName] = computedValue;
}
}
}
console.debug(`Theme Engine: Captured ${Object.keys(capturedVars).length} variables.`);
void plugin.applyStyles();
await new Promise((resolve) => requestAnimationFrame(resolve));
return capturedVars;
};
export const getThemeDefaults = async (plugin: ThemeEngine): Promise<Record<string, string>> => {
const currentThemeMode = document.body.classList.contains('theme-dark') ? 'dark' : 'light';
if (plugin.cachedThemeDefaults && plugin.lastCachedThemeMode === currentThemeMode) {
return plugin.cachedThemeDefaults;
}
console.debug('Theme Engine: Cache miss or theme change. Refreshing defaults...');
const newDefaults = await captureCurrentComputedVars(plugin);
plugin.cachedThemeDefaults = newDefaults;
plugin.lastCachedThemeMode = currentThemeMode;
return newDefaults;
};

324
src/core/style-engine.ts Normal file
View file

@ -0,0 +1,324 @@
import { DEFAULT_VARS } from '../constants';
import type ThemeEngine from '../main';
import { flattenVars, isIconizeEnabled } from '../utils';
type StylableElement = Element & {
setCssProps: (props: Record<string, string | null>) => void;
};
type VaultWithConfigGetter = {
getConfig: (key: string) => string;
};
type ReloadableView = {
reload?: () => void;
};
export const applyCustomCssForProfile = (plugin: ThemeEngine, profileName: string): void => {
try {
const targetProfileName = profileName || plugin.settings.activeProfile;
const profile = plugin.settings.profiles?.[targetProfileName];
removeInjectedCustomCss(plugin);
if (!profile || !profile.isCssProfile || !profile.customCss) {
return;
}
plugin.setRuntimeStyle('cm-custom-css-for-profile', profile.customCss);
} catch (error) {
console.warn('applyCustomCssForProfile failed', error);
}
};
export const removeInjectedCustomCss = (plugin: ThemeEngine): void => {
try {
plugin.clearRuntimeStyle('cm-custom-css-for-profile');
const oldStyle = document.getElementById('cm-custom-css-for-profile');
if (oldStyle) oldStyle.remove();
} catch (error) {
console.warn(error);
}
};
export const applyCssSnippets = (plugin: ThemeEngine): void => {
removeCssSnippets(plugin);
const activeProfile = plugin.settings.profiles[plugin.settings.activeProfile];
if (!activeProfile) return;
const globalSnippets = plugin.settings.globalSnippets || [];
const profileSnippets = Array.isArray(activeProfile.snippets) ? activeProfile.snippets : [];
const allSnippets = [...globalSnippets, ...profileSnippets].filter(Boolean);
const enabledCss = allSnippets
.filter((snippet) => snippet.enabled && snippet.css)
.map((snippet) => {
const upgradedCss = snippet.css.replace(
/\bbody\s*(?=\{)/g,
'body.theme-dark, body.theme-light',
);
return `/* Snippet: ${snippet.name} ${snippet.isGlobal ? '(Global)' : ''} */\n${upgradedCss}`;
})
.join('\n\n');
if (enabledCss) {
plugin.setRuntimeStyle('cm-css-snippets', enabledCss);
}
};
export const removeCssSnippets = (plugin: ThemeEngine): void => {
plugin.clearRuntimeStyle('cm-css-snippets');
const el = document.getElementById('cm-css-snippets');
if (el) el.remove();
};
export const applyPendingNow = (plugin: ThemeEngine): void => {
try {
const pending = plugin.pendingVarUpdates || {};
const keys = Object.keys(pending);
if (keys.length === 0) {
plugin.app.workspace.trigger('css-change');
window.dispatchEvent(new Event('resize'));
return;
}
for (const key of keys) {
const value = pending[key] ?? null;
document.body.setCssProps({ [key]: value });
}
plugin.pendingVarUpdates = {};
plugin.app.workspace.trigger('css-change');
forceIconizeColors(plugin);
window.dispatchEvent(new Event('resize'));
} catch (error) {
console.error('Theme Engine: applyPendingNow failed', error);
}
};
export const refreshOpenGraphViews = async (plugin: ThemeEngine): Promise<void> => {
const graphLeaves = plugin.app.workspace.getLeavesOfType('graph');
if (graphLeaves.length === 0) {
return;
}
console.debug(
`Theme Engine: Found ${graphLeaves.length} graph(s). Applying Plan C (programmatic rebuild).`,
);
for (const leaf of graphLeaves) {
const currentState = leaf.getViewState();
await leaf.setViewState({
...currentState,
type: 'graph',
state: { ...currentState.state },
});
}
};
export const forceIconizeColors = (plugin: ThemeEngine): void => {
let computedIconizeColor = null;
try {
const cssVal = getComputedStyle(document.body).getPropertyValue('--iconize-icon-color');
if (cssVal) computedIconizeColor = cssVal.trim();
} catch (error) {
console.warn('Theme Engine: failed to read computed --iconize-icon-color', error);
computedIconizeColor = null;
}
const storedIconizeColor =
plugin.settings.profiles?.[plugin.settings.activeProfile]?.vars?.['--iconize-icon-color'];
const iconizeColor = plugin.settings.overrideIconizeColors
? computedIconizeColor || storedIconizeColor || null
: null;
document.querySelectorAll('.iconize-icon').forEach((iconNode) => {
const svg = iconNode.querySelector('svg');
if (!svg) return;
([svg, ...svg.querySelectorAll('*')] as StylableElement[]).forEach((el) => {
if (typeof el.setCssProps !== 'function') return;
if (!iconizeColor) {
el.setCssProps({
fill: null,
stroke: null,
});
return;
}
const originalFill = el.getAttribute('fill');
const originalStroke = el.getAttribute('stroke');
if (originalFill && originalFill !== 'none' && !originalFill.startsWith('url(')) {
el.setCssProps({ fill: iconizeColor });
}
if (originalStroke && originalStroke !== 'none') {
el.setCssProps({ stroke: iconizeColor });
}
});
});
};
export const removeOrphanedIconizeElements = (plugin: ThemeEngine): void => {
const iconizeInstalled = isIconizeEnabled(plugin.app);
if (iconizeInstalled) {
return;
}
let settingsChanged = false;
if (plugin.settings.overrideIconizeColors === true) {
console.debug('Theme Engine: Iconize plugin not found. Disabling override setting.');
plugin.settings.overrideIconizeColors = false;
settingsChanged = true;
}
const orphanedIcons = document.querySelectorAll('.iconize-icon');
if (orphanedIcons.length > 0) {
console.debug(
`Theme Engine: Found ${orphanedIcons.length} orphaned Iconize elements. Cleaning up...`,
);
orphanedIcons.forEach((icon) => icon.remove());
}
if (settingsChanged) {
void plugin.saveSettings().catch((err) => {
console.error('Failed to save settings after removing Iconize leftovers:', err);
});
}
};
export const applyStyles = async (plugin: ThemeEngine): Promise<void> => {
removeOrphanedIconizeElements(plugin);
clearStyles(plugin);
if (!plugin.settings.pluginEnabled) {
removeInjectedCustomCss(plugin);
return;
}
const profile = plugin.settings.profiles[plugin.settings.activeProfile];
if (!profile) {
console.error('Theme Engine: Active profile not found!');
return;
}
const profileVars = Object.entries(profile.vars);
if (profileVars.length > 0) {
const cssString = `body.theme-dark, body.theme-light {
${profileVars
.map(([key, value]) => (value ? `${key}: ${value};` : ''))
.filter(Boolean)
.join('\n ')}
}`;
plugin.setRuntimeStyle('cm-profile-variables', cssString);
}
forceIconizeColors(plugin);
setTimeout(() => forceIconizeColors(plugin), 100);
const themeType = profile.themeType || 'auto';
if (themeType === 'dark') {
document.body.classList.remove('theme-light');
document.body.classList.add('theme-dark');
} else if (themeType === 'light') {
document.body.classList.remove('theme-dark');
document.body.classList.add('theme-light');
} else {
const vaultWithConfig = plugin.app.vault as typeof plugin.app.vault & VaultWithConfigGetter;
const currentConfig = vaultWithConfig.getConfig('theme');
const isSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (currentConfig === 'obsidian' || (currentConfig === 'system' && isSystemDark)) {
document.body.classList.remove('theme-light');
document.body.classList.add('theme-dark');
} else {
document.body.classList.remove('theme-dark');
document.body.classList.add('theme-light');
}
}
applyCustomCssForProfile(plugin, plugin.settings.activeProfile);
applyCssSnippets(plugin);
const langCode = plugin.settings.language;
const customLang = plugin.settings.customLanguages?.[langCode];
const isCoreRtlLang = langCode === 'ar' || langCode === 'fa';
const isCustomRtlLang = customLang?.isRtl === true;
const isRtlEnabled = plugin.settings.useRtlLayout;
const isRTL = (isCoreRtlLang || isCustomRtlLang) && isRtlEnabled;
if (isRTL) {
document.body.classList.add('theme-engine-rtl');
} else {
document.body.classList.remove('theme-engine-rtl');
}
await plugin.applyBackgroundMedia();
plugin.app.workspace.trigger('css-change');
window.dispatchEvent(new Event('resize'));
setTimeout(() => {
plugin.app.workspace.trigger('css-change');
const graphLeaves = plugin.app.workspace.getLeavesOfType('graph');
graphLeaves.forEach((leaf) => {
const view = leaf.view as ReloadableView | null;
if (view?.reload) {
view.reload();
}
});
}, 300);
};
export const clearStyles = (plugin: ThemeEngine): void => {
plugin.clearRuntimeStyle('cm-profile-variables');
plugin.clearRuntimeStyle('cm-dynamic-notice-styles');
const profileStyleEl = document.getElementById('cm-profile-variables');
if (profileStyleEl) {
profileStyleEl.remove();
}
removeCssSnippets(plugin);
const allVars = new Set();
Object.keys(flattenVars(DEFAULT_VARS)).forEach((key) => allVars.add(key));
for (const profileName in plugin.settings.profiles) {
const profile = plugin.settings.profiles[profileName];
if (profile && profile.vars) {
Object.keys(profile.vars).forEach((key) => allVars.add(key));
}
}
allVars.forEach((key: string) => {
document.body.setCssProps({ [key]: null });
});
document.querySelectorAll('.iconize-icon').forEach((iconNode) => {
const svg = iconNode.querySelector('svg');
if (!svg) return;
([svg, ...svg.querySelectorAll('*')] as StylableElement[]).forEach((el) => {
if (typeof el.setCssProps !== 'function') return;
el.setCssProps({
fill: null,
stroke: null,
});
});
});
const styleId = 'theme-engine-overrides';
const overrideStyleEl = document.getElementById(styleId);
if (overrideStyleEl) {
overrideStyleEl.remove();
}
plugin.app.workspace.trigger('css-change');
document.body.classList.remove('theme-engine-rtl');
plugin._clearBackgroundMedia();
};

File diff suppressed because it is too large Load diff

312
src/ui/settings/render.ts Normal file
View file

@ -0,0 +1,312 @@
import { ButtonComponent, Notice, Setting, SettingGroup, setIcon } from 'obsidian';
import { CORE_LANGUAGES, LocaleCode } from '../../i18n/types';
import { loadLanguage, t } from '../../i18n/strings';
import type { ThemeEngineSettingTab } from '../settingsTab';
import { drawColorPickers } from '../components/color-pickers';
import { drawCssSnippetsUI } from '../components/snippets-ui';
import { drawImportExport } from '../components/import-export';
import { drawLikePluginCard } from '../components/like-plugin-card';
import { drawOptionsSection } from '../components/options-section';
import { drawProfileManager } from '../components/profile-manager';
import {
AddNewLanguageModal,
ConfirmationModal,
LanguageInfoModal,
LanguageSettingsModal,
LanguageTranslatorModal,
} from '../modals';
import { debounce } from '../../utils';
export const renderSettingsTab = async (tab: ThemeEngineSettingTab): Promise<void> => {
const themeDefaults = await tab.plugin.getThemeDefaults();
const { containerEl } = tab;
containerEl.classList.add('theme-engine-settings-tab');
containerEl.empty();
containerEl.classList.add('theme-engine-hidden');
const langCode = tab.plugin.settings.language;
const customLang = tab.plugin.settings.customLanguages?.[langCode];
const isCoreRtlLang = langCode === 'ar' || langCode === 'fa';
const isCustomRtlLang = customLang?.isRtl === true;
const isRtlCapable = isCoreRtlLang || isCustomRtlLang;
const isRtlEnabled = tab.plugin.settings.useRtlLayout;
const isRTL = isRtlCapable && isRtlEnabled;
const isCustom = !!customLang;
const isCore = !!CORE_LANGUAGES[langCode as LocaleCode];
tab.containerEl.setAttribute('dir', isRTL ? 'rtl' : 'ltr');
const topSettingsContainer = containerEl.createDiv({
cls: 'cm-top-settings-group',
});
let createTopSetting: () => Setting;
if (typeof SettingGroup === 'function') {
const topGroup = new SettingGroup(topSettingsContainer);
topGroup.addClass('cm-native-setting-group');
createTopSetting = () => {
let createdSetting: Setting | null = null;
topGroup.addSetting((setting) => {
createdSetting = setting;
});
if (!createdSetting) {
console.warn('Theme Engine: Failed to create top setting group item. Falling back.');
return new Setting(topSettingsContainer);
}
return createdSetting;
};
} else {
topSettingsContainer.classList.add('cm-fallback-setting-group');
const topSettingsBody = topSettingsContainer.createDiv({
cls: 'cm-setting-group-body',
});
createTopSetting = () => new Setting(topSettingsBody);
}
createTopSetting()
.setName(t('settings.enablePlugin'))
.setDesc(t('settings.enablePluginDesc'))
.addToggle((toggle) => {
toggle.setValue(tab.plugin.settings.pluginEnabled).onChange(async (value) => {
tab.plugin.settings.pluginEnabled = value;
if (value) {
tab.plugin.enableObservers();
} else {
tab.plugin.disableObservers();
}
await tab.plugin.saveSettings();
tab.plugin.restartColorUpdateLoop();
new Notice(value ? t('notices.pluginEnabled') : t('notices.pluginDisabled'));
});
});
const languageSetting = createTopSetting()
.setName(t('settings.language'))
.setDesc(t('settings.languageDesc'));
const langIcon = languageSetting.nameEl.createSpan({
cls: 'cm-setting-icon',
});
setIcon(langIcon, 'languages');
languageSetting.nameEl.prepend(langIcon);
const flyoutContainer = languageSetting.controlEl.createDiv({
cls: 'cm-flyout-menu-container',
});
const buttonListEl = flyoutContainer.createDiv({
cls: 'cm-flyout-menu-buttons',
});
const triggerBtn = new ButtonComponent(flyoutContainer)
.setIcon('settings')
.setTooltip(t('tooltips.langMenu'))
.setClass('cm-flyout-trigger-btn')
.onClick(() => {
buttonListEl.toggleClass('is-open', !buttonListEl.classList.contains('is-open'));
});
triggerBtn.buttonEl.classList.add('cm-control-icon-button');
new ButtonComponent(buttonListEl)
.setIcon('info')
.setTooltip(t('tooltips.langInfo'))
.setClass('cm-control-icon-button')
.onClick(() => {
new LanguageInfoModal(tab.app, tab.plugin).open();
});
new ButtonComponent(buttonListEl)
.setIcon('pencil')
.setTooltip(t('tooltips.editLang'))
.setClass('cm-control-icon-button')
.onClick(() => {
const selectedLangCode = tab.plugin.settings.language;
new LanguageTranslatorModal(tab.app, tab.plugin, tab, selectedLangCode).open();
});
new ButtonComponent(buttonListEl)
.setIcon('plus')
.setTooltip(t('modals.addLang.title'))
.setClass('cm-control-icon-button')
.onClick(() => {
new AddNewLanguageModal(tab.app, tab.plugin, tab).open();
});
if (isRtlCapable) {
new ButtonComponent(buttonListEl)
.setIcon('settings-2')
.setTooltip(t('settings.languageSettingsModalTitle'))
.setClass('cm-control-icon-button')
.onClick(() => {
new LanguageSettingsModal(tab.app, tab.plugin).open();
});
}
if (isCore && isCustom) {
const restoreBtn = new ButtonComponent(buttonListEl)
.setIcon('history')
.setTooltip(t('tooltips.restoreDefaultLang'))
.setClass('mod-warning')
.onClick(() => {
new ConfirmationModal(
tab.app,
tab.plugin,
t('modals.confirmation.restoreLangTitle'),
t('modals.confirmation.restoreLangDesc', CORE_LANGUAGES[langCode as LocaleCode]),
() => {
void (async () => {
if (tab.plugin.settings.customLanguages) {
delete tab.plugin.settings.customLanguages[langCode];
loadLanguage(tab.plugin.settings);
await tab.plugin.saveSettings();
tab.display();
new Notice(t('notices.langRestored'));
}
})().catch((err) => {
console.error('Failed to restore language:', err);
});
},
{ buttonText: t('buttons.restore'), buttonClass: 'mod-warning' },
).open();
});
restoreBtn.buttonEl.classList.add('cm-control-icon-button');
}
if (isCustom && !isCore) {
const deleteBtn = new ButtonComponent(buttonListEl)
.setIcon('trash')
.setTooltip(t('buttons.delete') + ` (${customLang.languageName})`)
.setClass('mod-warning')
.onClick(() => {
new ConfirmationModal(
tab.app,
tab.plugin,
t('modals.confirmation.deleteLangTitle'),
t('modals.confirmation.deleteLangDesc', customLang.languageName),
() => {
void (async () => {
if (!tab.plugin.settings.customLanguages) return;
delete tab.plugin.settings.customLanguages[langCode];
tab.plugin.settings.language = 'en';
loadLanguage(tab.plugin.settings);
await tab.plugin.saveSettings();
tab.display();
new Notice(t('notices.langDeleted', customLang.languageName));
})().catch((err) => {
console.error('Failed to delete custom language:', err);
});
},
{ buttonText: t('buttons.delete'), buttonClass: 'mod-warning' },
).open();
});
deleteBtn.buttonEl.classList.add('cm-control-icon-button');
}
languageSetting.addDropdown((dropdown) => {
const customLangs = tab.plugin.settings.customLanguages || {};
for (const code in CORE_LANGUAGES) {
let displayName = CORE_LANGUAGES[code as LocaleCode];
if (customLangs[code]) {
displayName = customLangs[code].languageName;
}
dropdown.addOption(code, displayName);
}
const customCodes = Object.keys(customLangs);
for (const code of customCodes) {
if (CORE_LANGUAGES[code as LocaleCode]) continue;
const langName = customLangs[code].languageName;
dropdown.addOption(code, langName);
}
dropdown.setValue(tab.plugin.settings.language);
dropdown.onChange(async (value) => {
tab.plugin.settings.language = value;
loadLanguage(tab.plugin.settings);
await tab.plugin.saveSettings();
tab.display();
});
});
tab.staticContentContainer = containerEl.createDiv({
cls: 'cm-static-sections',
});
drawProfileManager(tab.staticContentContainer, tab);
drawImportExport(tab.staticContentContainer, tab);
drawOptionsSection(tab.staticContentContainer, tab);
tab.staticContentContainer.createEl('hr');
drawCssSnippetsUI(tab.staticContentContainer, tab);
containerEl.createEl('hr', { cls: 'cm-search-divider' });
tab.initSearchUI(containerEl);
drawColorPickers(tab.containerEl, tab, themeDefaults);
containerEl.createEl('hr');
const noResultsBadge = containerEl.createDiv({
cls: 'cm-search-empty-badge is-hidden',
});
const noResultsIcon = noResultsBadge.createDiv({
cls: 'cm-search-empty-icon',
});
setIcon(noResultsIcon, 'search-x');
noResultsBadge.createDiv({
cls: 'cm-search-empty-title',
text: t('settings.noResultsFound'),
});
noResultsBadge.createDiv({
cls: 'cm-search-empty-subtitle',
text: t('settings.noResultsHint'),
});
tab.noSearchResultsEl = noResultsBadge;
tab.likeCardEl = drawLikePluginCard(containerEl, tab);
tab._applySearchFilter();
if (tab._searchState.query) {
setTimeout(() => {
const firstVisibleRow = tab.containerEl.querySelector<HTMLElement>(
'.cm-var-row:not(.cm-hidden)',
);
if (firstVisibleRow) {
firstVisibleRow.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, 0);
}
const scrollContainer = tab.containerEl.closest<HTMLElement>('.vertical-tab-content');
if (!scrollContainer) {
console.warn('Theme Engine: Could not find scroll container.');
} else {
const debouncedScrollSave = debounce(() => {
tab.plugin.settings.lastScrollPosition = scrollContainer.scrollTop;
void tab.plugin.saveData(tab.plugin.settings).catch((err) => {
console.error('Failed to save scroll position:', err);
});
}, 200);
tab.plugin.registerDomEvent(scrollContainer, 'scroll', debouncedScrollSave);
if (!tab._searchState.query && tab.plugin.settings.lastScrollPosition) {
scrollContainer.scrollTo({
top: tab.plugin.settings.lastScrollPosition,
behavior: 'auto',
});
}
}
containerEl.classList.remove('theme-engine-hidden');
};

339
src/ui/settings/search.ts Normal file
View file

@ -0,0 +1,339 @@
import { DropdownComponent, SearchComponent, setIcon } from 'obsidian';
import { DEFAULT_VARS } from '../../constants';
import { t } from '../../i18n/strings';
import type { ThemeEngineSettingTab } from '../settingsTab';
import { debounce } from '../../utils';
export type SearchState = {
query: string;
regex: boolean;
caseSensitive: boolean;
section: string;
};
export const clearSearchAndFilters = (tab: ThemeEngineSettingTab): void => {
if (!tab.searchInput || !tab.sectionSelect) return;
tab.searchInput.value = '';
tab.sectionSelect.value = '';
tab._searchState.query = '';
tab._searchState.section = '';
tab.plugin.settings.lastSearchQuery = '';
tab.plugin.settings.lastSearchSection = '';
void tab.plugin.saveData(tab.plugin.settings).catch((err) => {
console.error('Failed to save search input state:', err);
});
const filterButton = tab.containerEl.querySelector('button[data-cm-action="filter"]');
const filterOptionsContainer = tab.containerEl.querySelector('.cm-search-filter-options');
if (filterButton) filterButton.classList.remove('is-active');
if (filterOptionsContainer) filterOptionsContainer.classList.add('is-hidden');
tab._applySearchFilter();
};
export const initSearchUI = (tab: ThemeEngineSettingTab, containerEl: HTMLElement): void => {
tab._searchState = {
query: tab.plugin.settings.lastSearchQuery || '',
regex: false,
caseSensitive: false,
section: tab.plugin.settings.lastSearchSection || '',
};
const searchShell = containerEl.createDiv({
cls: 'cm-search-shell',
});
tab.searchContainer = searchShell;
const searchBarContainer = searchShell.createDiv({
cls: 'cm-search-bar-container',
});
const searchInputContainer = searchBarContainer.createDiv({
cls: 'cm-search-input-container',
});
const searchComponent = new SearchComponent(searchInputContainer)
.setPlaceholder(t('settings.searchPlaceholder'))
.setValue(tab._searchState.query)
.onChange((value) => {
if (tab._searchState.regex) return;
tab._searchState.query = value;
tab.plugin.settings.lastSearchQuery = value;
void tab.plugin.saveData(tab.plugin.settings).catch((err) => {
console.error('Failed to save search/filter reset state:', err);
});
debouncedFilter();
});
tab.searchInput = searchComponent.inputEl;
tab.searchInput.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter' && tab._searchState.regex) {
tab._searchState.query = (event.target as HTMLInputElement).value;
debouncedFilter();
}
});
const searchActions = searchBarContainer.createDiv({
cls: 'cm-search-actions',
});
tab.caseToggle = searchActions.createEl('button', {
cls: 'cm-search-action-btn',
});
tab.caseToggle.textContent = 'Aa';
tab.caseToggle.setAttr('aria-label', t('settings.ariaCase'));
tab.regexToggle = searchActions.createEl('button', {
cls: 'cm-search-action-btn',
});
setIcon(tab.regexToggle, 'regex');
tab.regexToggle.setAttr('aria-label', t('settings.ariaRegex'));
const filterOptionsContainer = searchActions.createDiv({
cls: 'cm-search-filter-options is-hidden',
});
const dropdown = new DropdownComponent(filterOptionsContainer)
.addOption('', t('settings.allSections'))
.onChange(async (value) => {
tab._searchState.section = value;
tab.plugin.settings.lastSearchSection = value;
await tab.plugin.saveData(tab.plugin.settings);
filterOptionsContainer.classList.toggle('is-filter-active', value !== '');
debouncedFilter();
});
dropdown.setValue(tab._searchState.section);
if (tab._searchState.section) {
filterOptionsContainer.classList.remove('is-hidden');
}
try {
Object.keys(DEFAULT_VARS || {}).forEach((category) => {
let categoryKey: string;
const lowerCategory = category.toLowerCase();
if (lowerCategory === 'interactive elements') categoryKey = 'interactive';
else if (lowerCategory === 'ui elements') categoryKey = 'ui';
else if (lowerCategory === 'graph view') categoryKey = 'graph';
else if (lowerCategory === 'plugin integrations') categoryKey = 'pluginintegrations';
else categoryKey = lowerCategory.replace(/ /g, '');
const translatedCategory = t(`categories.${categoryKey}`) || category;
dropdown.addOption(category, translatedCategory);
});
} catch {
// ignore invalid category translation
}
const activeProfile = tab.plugin.settings.profiles[tab.plugin.settings.activeProfile];
if (activeProfile?.customVarMetadata && Object.keys(activeProfile.customVarMetadata).length > 0) {
dropdown.addOption('Custom', t('categories.custom'));
}
dropdown.selectEl.classList.add('cm-search-small');
tab.sectionSelect = dropdown.selectEl;
const filterButton = searchActions.createEl('button', {
cls: 'cm-search-action-btn',
});
setIcon(filterButton, 'sliders-horizontal');
filterButton.dataset.cmAction = 'filter';
if (tab._searchState.section) filterButton.classList.add('is-active');
const debouncedFilter = debounce(() => tab._applySearchFilter(), 180);
tab.caseToggle.addEventListener('click', () => {
tab._searchState.caseSensitive = !tab._searchState.caseSensitive;
tab.caseToggle.toggleClass('is-active', tab._searchState.caseSensitive);
debouncedFilter();
});
tab.regexToggle.addEventListener('click', () => {
tab._searchState.regex = !tab._searchState.regex;
tab.regexToggle.toggleClass('is-active', tab._searchState.regex);
if (tab._searchState.regex) {
searchComponent.setPlaceholder(t('settings.regexPlaceholder'));
} else {
searchComponent.setPlaceholder(t('settings.searchPlaceholder'));
}
tab._searchState.query = tab.searchInput.value;
debouncedFilter();
});
filterButton.addEventListener('click', () => {
if (filterButton.classList.contains('is-active')) {
tab._clearSearchAndFilters();
} else {
filterOptionsContainer.classList.remove('is-hidden');
filterButton.classList.add('is-active');
}
});
tab.sectionSelect.addEventListener('change', (event: Event) => {
tab._searchState.section = (event.target as HTMLSelectElement).value;
debouncedFilter();
});
};
export const applySearchFilter = (tab: ThemeEngineSettingTab): void => {
const state = tab._searchState;
const activeProfile = tab.plugin.settings.profiles[tab.plugin.settings.activeProfile];
const isSearching = state.query.trim().length > 0 || state.section !== '';
const rows = Array.from(
tab.containerEl.querySelectorAll<HTMLElement>('.cm-var-row, .cm-searchable-row'),
);
let queryRegex: RegExp | null = null;
if (state.query && state.query.trim()) {
if (state.regex) {
try {
queryRegex = new RegExp(state.query, state.caseSensitive ? '' : 'i');
} catch {
queryRegex = null;
}
}
}
rows.forEach((row) => {
const varName = row.dataset.var || '';
const snippetName = row.dataset.name || '';
const textInput = row.querySelector<HTMLInputElement>("input[type='text']");
const varValue = textInput ? textInput.value.trim() : '';
let displayName = '';
let description = '';
const customMeta = activeProfile?.customVarMetadata?.[varName];
if (customMeta) {
displayName = customMeta.name;
description = customMeta.desc;
} else {
displayName = t(`colors.names.${varName}`) || snippetName;
description = t(`colors.descriptions.${varName}`) || '';
}
if (state.section && state.section !== row.dataset.category) {
row.classList.add('cm-hidden');
return;
}
if (state.query && state.query.trim()) {
const query = state.query.trim();
let isMatch = false;
if (state.regex && queryRegex) {
isMatch =
queryRegex.test(varName) ||
queryRegex.test(varValue) ||
queryRegex.test(displayName) ||
queryRegex.test(description);
} else {
const queryLower = state.caseSensitive ? query : query.toLowerCase();
const nameLower = state.caseSensitive ? varName : varName.toLowerCase();
const valueLower = state.caseSensitive ? varValue : varValue.toLowerCase();
const displayNameLower = state.caseSensitive ? displayName : displayName.toLowerCase();
const descriptionLower = state.caseSensitive ? description : description.toLowerCase();
isMatch =
nameLower.includes(queryLower) ||
valueLower.includes(queryLower) ||
displayNameLower.includes(queryLower) ||
descriptionLower.includes(queryLower);
}
if (!isMatch) {
row.classList.add('cm-hidden');
return;
}
}
row.classList.remove('cm-hidden');
tab._highlightRowMatches(row, state);
});
const headings = tab.containerEl.querySelectorAll<HTMLElement>('.cm-category-container');
headings.forEach((heading) => {
const category = heading.dataset.category;
const hasVisibleRows = tab.containerEl.querySelector(
`.cm-var-row[data-category="${category}"]:not(.cm-hidden)`,
);
if (hasVisibleRows) {
heading.classList.remove('cm-hidden');
} else {
heading.classList.add('cm-hidden');
}
});
const hasVisibleSearchResults = rows.some((row) => !row.classList.contains('cm-hidden'));
const showNoResults = isSearching && !hasVisibleSearchResults;
if (tab.noSearchResultsEl) {
tab.noSearchResultsEl.classList.toggle('is-hidden', !showNoResults);
}
if (tab.likeCardEl) {
tab.likeCardEl.classList.toggle('is-hidden', showNoResults);
}
};
export const highlightRowMatches = (row: HTMLElement, state: SearchState): void => {
const query = state.query.trim();
const highlightElement = (element: HTMLElement | null) => {
if (!element) return;
if (!element.dataset.originalText) {
element.dataset.originalText = element.textContent || '';
}
const originalText = element.dataset.originalText;
element.empty();
if (!query) {
element.setText(originalText);
return;
}
const flags = state.caseSensitive ? 'g' : 'gi';
let regex: RegExp;
try {
regex = state.regex
? new RegExp(query, flags)
: new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
let lastIndex = 0;
let match;
while ((match = regex.exec(originalText)) !== null) {
if (match.index > lastIndex) {
element.appendText(originalText.substring(lastIndex, match.index));
}
element.createSpan({ cls: 'cm-search-match', text: match[0] });
lastIndex = regex.lastIndex;
}
if (lastIndex < originalText.length) {
element.appendText(originalText.substring(lastIndex));
}
} catch {
element.setText(originalText);
}
};
const nameEl = row.querySelector<HTMLElement>('.cm-var-name');
const descEl = row.querySelector<HTMLElement>('.setting-item-description');
highlightElement(nameEl);
highlightElement(descEl);
};

View file

@ -1,34 +1,17 @@
import { import { App, ButtonComponent, Notice, PluginSettingTab } from 'obsidian';
App, import Sortable from 'sortablejs';
ButtonComponent,
DropdownComponent,
Notice,
PluginSettingTab,
Setting,
SettingGroup,
setIcon,
SearchComponent,
} from 'obsidian';
import { DEFAULT_VARS, TEXT_TO_BG_MAP } from '../constants'; import { DEFAULT_VARS, TEXT_TO_BG_MAP } from '../constants';
import { t } from '../i18n/strings'; import { t } from '../i18n/strings';
import type ThemeEngine from '../main'; import type ThemeEngine from '../main';
import { flattenVars, getAccessibilityRating, getContrastRatio, debounce } from '../utils'; import { flattenVars, getAccessibilityRating, getContrastRatio } from '../utils';
import { drawColorPickers } from './components/color-pickers'; import { renderSettingsTab } from './settings/render';
import { drawImportExport } from './components/import-export';
import { drawLikePluginCard } from './components/like-plugin-card';
import { drawOptionsSection } from './components/options-section';
import { drawProfileManager } from './components/profile-manager';
import { drawCssSnippetsUI } from './components/snippets-ui';
import { LanguageSettingsModal } from './modals';
import Sortable from 'sortablejs';
import { loadLanguage } from '../i18n/strings';
import { CORE_LANGUAGES, LocaleCode } from '../i18n/types';
import { import {
AddNewLanguageModal, applySearchFilter as applySearchFilterCore,
LanguageTranslatorModal, clearSearchAndFilters,
ConfirmationModal, highlightRowMatches as highlightRowMatchesCore,
LanguageInfoModal, initSearchUI as initSearchUICore,
} from './modals'; type SearchState,
} from './settings/search';
export class ThemeEngineSettingTab extends PluginSettingTab { export class ThemeEngineSettingTab extends PluginSettingTab {
plugin: ThemeEngine; plugin: ThemeEngine;
@ -37,12 +20,7 @@ export class ThemeEngineSettingTab extends PluginSettingTab {
caseToggle: HTMLButtonElement; caseToggle: HTMLButtonElement;
regexToggle: HTMLButtonElement; regexToggle: HTMLButtonElement;
sectionSelect: HTMLSelectElement; sectionSelect: HTMLSelectElement;
_searchState: { _searchState: SearchState;
query: string;
regex: boolean;
caseSensitive: boolean;
section: string;
};
staticContentContainer: HTMLDivElement; staticContentContainer: HTMLDivElement;
resetPinBtn: ButtonComponent | null = null; resetPinBtn: ButtonComponent | null = null;
pinBtn: ButtonComponent | null = null; pinBtn: ButtonComponent | null = null;
@ -92,340 +70,19 @@ export class ThemeEngineSettingTab extends PluginSettingTab {
} }
_clearSearchAndFilters() { _clearSearchAndFilters() {
if (!this.searchInput || !this.sectionSelect) return; clearSearchAndFilters(this);
this.searchInput.value = '';
this.sectionSelect.value = '';
this._searchState.query = '';
this._searchState.section = '';
// Clear saved settings
this.plugin.settings.lastSearchQuery = '';
this.plugin.settings.lastSearchSection = '';
void this.plugin.saveData(this.plugin.settings).catch((err) => {
console.error('Failed to save search input state:', err);
});
const filterButton = this.containerEl.querySelector('button[data-cm-action="filter"]');
const filterOptionsContainer = this.containerEl.querySelector('.cm-search-filter-options');
if (filterButton) filterButton.classList.remove('is-active');
if (filterOptionsContainer) filterOptionsContainer.classList.add('is-hidden');
this._applySearchFilter();
} }
initSearchUI(containerEl: HTMLElement) { initSearchUI(containerEl: HTMLElement) {
// Load saved section here initSearchUICore(this, containerEl);
this._searchState = {
query: this.plugin.settings.lastSearchQuery || '',
regex: false,
caseSensitive: false,
section: this.plugin.settings.lastSearchSection || '',
};
const searchShell = containerEl.createDiv({
cls: 'cm-search-shell',
});
this.searchContainer = searchShell;
const searchBarContainer = searchShell.createDiv({
cls: 'cm-search-bar-container',
});
// --- 1. Input field section ---
const searchInputContainer = searchBarContainer.createDiv({
cls: 'cm-search-input-container',
});
const searchComponent = new SearchComponent(searchInputContainer)
.setPlaceholder(t('settings.searchPlaceholder'))
.setValue(this._searchState.query)
.onChange((value) => {
if (this._searchState.regex) return;
this._searchState.query = value;
this.plugin.settings.lastSearchQuery = value;
void this.plugin.saveData(this.plugin.settings).catch((err) => {
console.error('Failed to save search/filter reset state:', err);
});
debouncedFilter();
});
this.searchInput = searchComponent.inputEl;
this.searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' && this._searchState.regex) {
this._searchState.query = (e.target as HTMLInputElement).value;
debouncedFilter();
}
});
// --- 2. Control buttons section ---
const searchActions = searchBarContainer.createDiv({
cls: 'cm-search-actions',
});
this.caseToggle = searchActions.createEl('button', {
cls: 'cm-search-action-btn',
});
this.caseToggle.textContent = 'Aa';
this.caseToggle.setAttr('aria-label', t('settings.ariaCase'));
this.regexToggle = searchActions.createEl('button', {
cls: 'cm-search-action-btn',
});
setIcon(this.regexToggle, 'regex');
this.regexToggle.setAttr('aria-label', t('settings.ariaRegex'));
const filterOptionsContainer = searchActions.createDiv({
cls: 'cm-search-filter-options is-hidden',
});
const dropdown = new DropdownComponent(filterOptionsContainer)
.addOption('', t('settings.allSections'))
.onChange(async (value) => {
this._searchState.section = value;
// Save the filter section
this.plugin.settings.lastSearchSection = value;
await this.plugin.saveData(this.plugin.settings);
filterOptionsContainer.classList.toggle('is-filter-active', value !== '');
debouncedFilter();
});
// Restore filter state
dropdown.setValue(this._searchState.section);
if (this._searchState.section) {
filterOptionsContainer.classList.remove('is-hidden');
}
try {
Object.keys(DEFAULT_VARS || {}).forEach((category) => {
let categoryKey: string;
const lowerCategory = category.toLowerCase();
if (lowerCategory === 'interactive elements') categoryKey = 'interactive';
else if (lowerCategory === 'ui elements') categoryKey = 'ui';
else if (lowerCategory === 'graph view') categoryKey = 'graph';
else if (lowerCategory === 'plugin integrations') categoryKey = 'pluginintegrations';
else categoryKey = lowerCategory.replace(/ /g, '');
const translatedCategory = t(`categories.${categoryKey}`) || category;
dropdown.addOption(category, translatedCategory);
});
} catch {
// ignore invalid category translation
}
const activeProfile = this.plugin.settings.profiles[this.plugin.settings.activeProfile];
if (
activeProfile?.customVarMetadata &&
Object.keys(activeProfile.customVarMetadata).length > 0
) {
dropdown.addOption('Custom', t('categories.custom'));
}
dropdown.selectEl.classList.add('cm-search-small');
this.sectionSelect = dropdown.selectEl;
const filterButton = searchActions.createEl('button', {
cls: 'cm-search-action-btn',
});
setIcon(filterButton, 'sliders-horizontal');
filterButton.dataset.cmAction = 'filter';
// Activate button if section is saved
if (this._searchState.section) filterButton.classList.add('is-active');
const debouncedFilter = debounce(() => this._applySearchFilter(), 180);
this.caseToggle.addEventListener('click', () => {
this._searchState.caseSensitive = !this._searchState.caseSensitive;
this.caseToggle.toggleClass('is-active', this._searchState.caseSensitive);
debouncedFilter();
});
this.regexToggle.addEventListener('click', () => {
this._searchState.regex = !this._searchState.regex;
this.regexToggle.toggleClass('is-active', this._searchState.regex);
if (this._searchState.regex) {
searchComponent.setPlaceholder(t('settings.regexPlaceholder'));
} else {
searchComponent.setPlaceholder(t('settings.searchPlaceholder'));
}
this._searchState.query = this.searchInput.value;
debouncedFilter();
});
filterButton.addEventListener('click', () => {
if (filterButton.classList.contains('is-active')) {
this._clearSearchAndFilters();
} else {
filterOptionsContainer.classList.remove('is-hidden');
filterButton.classList.add('is-active');
}
});
this.sectionSelect.addEventListener('change', (e: Event) => {
this._searchState.section = (e.target as HTMLSelectElement).value;
debouncedFilter();
});
} }
_applySearchFilter() { _applySearchFilter() {
const s = this._searchState; applySearchFilterCore(this);
const activeProfile = this.plugin.settings.profiles[this.plugin.settings.activeProfile];
const isSearching = s.query.trim().length > 0 || s.section !== '';
const rows = Array.from(
this.containerEl.querySelectorAll<HTMLElement>('.cm-var-row, .cm-searchable-row'),
);
let qRegex: RegExp | null = null;
if (s.query && s.query.trim()) {
if (s.regex) {
try {
qRegex = new RegExp(s.query, s.caseSensitive ? '' : 'i');
} catch {
qRegex = null;
}
}
}
rows.forEach((row) => {
const varName = row.dataset.var || '';
const snippetName = row.dataset.name || '';
const textInput = row.querySelector<HTMLInputElement>("input[type='text']");
const varValue = textInput ? textInput.value.trim() : '';
let displayName = '';
let description = '';
const customMeta = activeProfile?.customVarMetadata?.[varName];
if (customMeta) {
displayName = customMeta.name;
description = customMeta.desc;
} else {
displayName = t(`colors.names.${varName}`) || snippetName;
description = t(`colors.descriptions.${varName}`) || '';
}
if (s.section && s.section !== row.dataset.category) {
row.classList.add('cm-hidden');
return;
}
if (s.query && s.query.trim()) {
const q = s.query.trim();
let isMatch = false;
if (s.regex && qRegex) {
isMatch =
qRegex.test(varName) ||
qRegex.test(varValue) ||
qRegex.test(displayName) ||
qRegex.test(description);
} else {
const queryLower = s.caseSensitive ? q : q.toLowerCase();
const nameLower = s.caseSensitive ? varName : varName.toLowerCase();
const valueLower = s.caseSensitive ? varValue : varValue.toLowerCase();
const displayNameLower = s.caseSensitive ? displayName : displayName.toLowerCase();
const descriptionLower = s.caseSensitive ? description : description.toLowerCase();
isMatch =
nameLower.includes(queryLower) ||
valueLower.includes(queryLower) ||
displayNameLower.includes(queryLower) ||
descriptionLower.includes(queryLower);
}
if (!isMatch) {
row.classList.add('cm-hidden');
return;
}
}
row.classList.remove('cm-hidden');
this._highlightRowMatches(row, s);
});
const headings = this.containerEl.querySelectorAll<HTMLElement>('.cm-category-container');
headings.forEach((heading) => {
const category = heading.dataset.category;
const hasVisibleRows = this.containerEl.querySelector(
`.cm-var-row[data-category="${category}"]:not(.cm-hidden)`,
);
if (hasVisibleRows) {
heading.classList.remove('cm-hidden');
} else {
heading.classList.add('cm-hidden');
}
});
const hasVisibleSearchResults = rows.some((row) => !row.classList.contains('cm-hidden'));
const showNoResults = isSearching && !hasVisibleSearchResults;
if (this.noSearchResultsEl) {
this.noSearchResultsEl.classList.toggle('is-hidden', !showNoResults);
}
if (this.likeCardEl) {
this.likeCardEl.classList.toggle('is-hidden', showNoResults);
}
} }
_highlightRowMatches(row: HTMLElement, state: typeof this._searchState) { _highlightRowMatches(row: HTMLElement, state: SearchState) {
const query = state.query.trim(); highlightRowMatchesCore(row, state);
const highlightElement = (element: HTMLElement | null) => {
if (!element) return;
if (!element.dataset.originalText) {
element.dataset.originalText = element.textContent || '';
}
const originalText = element.dataset.originalText;
element.empty();
if (!query) {
element.setText(originalText);
return;
}
const flags = state.caseSensitive ? 'g' : 'gi';
let regex: RegExp;
try {
regex = state.regex
? new RegExp(query, flags)
: new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
let lastIndex = 0;
let match;
while ((match = regex.exec(originalText)) !== null) {
if (match.index > lastIndex) {
element.appendText(originalText.substring(lastIndex, match.index));
}
element.createSpan({ cls: 'cm-search-match', text: match[0] });
lastIndex = regex.lastIndex;
}
if (lastIndex < originalText.length) {
element.appendText(originalText.substring(lastIndex));
}
} catch {
element.setText(originalText);
}
};
const nameEl = row.querySelector<HTMLElement>('.cm-var-name');
const descEl = row.querySelector<HTMLElement>('.setting-item-description');
highlightElement(nameEl);
highlightElement(descEl);
} }
_updatePinButtons() { _updatePinButtons() {
@ -452,12 +109,12 @@ export class ThemeEngineSettingTab extends PluginSettingTab {
} }
_getCurrentProfileJson() { _getCurrentProfileJson() {
const p = this.plugin.settings.profiles?.[this.plugin.settings.activeProfile]; const profile = this.plugin.settings.profiles?.[this.plugin.settings.activeProfile];
if (!p) return null; if (!profile) return null;
return { return {
name: this.plugin.settings.activeProfile, name: this.plugin.settings.activeProfile,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
profile: p, profile,
}; };
} }
@ -481,12 +138,12 @@ export class ThemeEngineSettingTab extends PluginSettingTab {
type: 'application/json', type: 'application/json',
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const anchor = document.createElement('a');
a.download = `${this.plugin.settings.activeProfile}.profile.json`; anchor.download = `${this.plugin.settings.activeProfile}.profile.json`;
a.href = url; anchor.href = url;
a.click(); anchor.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
a.remove(); anchor.remove();
new Notice(t('notices.exportSuccess')); new Notice(t('notices.exportSuccess'));
} }
@ -520,316 +177,12 @@ export class ThemeEngineSettingTab extends PluginSettingTab {
} }
display(): void { display(): void {
// call the async renderer and handle any error
void this.renderDisplay().catch((err) => { void this.renderDisplay().catch((err) => {
console.error('Error in SettingsTab.renderDisplay:', err); console.error('Error in SettingsTab.renderDisplay:', err);
}); });
} }
private async renderDisplay(): Promise<void> { private async renderDisplay(): Promise<void> {
const themeDefaults = await this.plugin.getThemeDefaults(); await renderSettingsTab(this);
const { containerEl } = this;
containerEl.classList.add('theme-engine-settings-tab');
containerEl.empty();
containerEl.classList.add('theme-engine-hidden');
const langCode = this.plugin.settings.language;
const customLang = this.plugin.settings.customLanguages?.[langCode];
const isCoreRtlLang = langCode === 'ar' || langCode === 'fa';
const isCustomRtlLang = customLang?.isRtl === true;
const isRtlCapable = isCoreRtlLang || isCustomRtlLang;
const isRtlEnabled = this.plugin.settings.useRtlLayout;
const isRTL = isRtlCapable && isRtlEnabled;
const isCustom = !!customLang;
const isCore = !!CORE_LANGUAGES[langCode as LocaleCode];
this.containerEl.setAttribute('dir', isRTL ? 'rtl' : 'ltr');
const topSettingsContainer = containerEl.createDiv({
cls: 'cm-top-settings-group',
});
let createTopSetting: () => Setting;
if (typeof SettingGroup === 'function') {
const topGroup = new SettingGroup(topSettingsContainer);
topGroup.addClass('cm-native-setting-group');
createTopSetting = () => {
let createdSetting: Setting | null = null;
topGroup.addSetting((setting) => {
createdSetting = setting;
});
if (!createdSetting) {
console.warn('Theme Engine: Failed to create top setting group item. Falling back.');
return new Setting(topSettingsContainer);
}
return createdSetting;
};
} else {
topSettingsContainer.classList.add('cm-fallback-setting-group');
const topSettingsBody = topSettingsContainer.createDiv({
cls: 'cm-setting-group-body',
});
createTopSetting = () => new Setting(topSettingsBody);
}
createTopSetting()
.setName(t('settings.enablePlugin'))
.setDesc(t('settings.enablePluginDesc'))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.pluginEnabled).onChange(async (value) => {
this.plugin.settings.pluginEnabled = value;
if (value) {
this.plugin.enableObservers();
} else {
this.plugin.disableObservers();
}
await this.plugin.saveSettings();
this.plugin.restartColorUpdateLoop();
new Notice(value ? t('notices.pluginEnabled') : t('notices.pluginDisabled'));
});
});
const languageSetting = createTopSetting()
.setName(t('settings.language'))
.setDesc(t('settings.languageDesc'));
const langIcon = languageSetting.nameEl.createSpan({
cls: 'cm-setting-icon',
});
setIcon(langIcon, 'languages');
languageSetting.nameEl.prepend(langIcon);
const flyoutContainer = languageSetting.controlEl.createDiv({
cls: 'cm-flyout-menu-container',
});
const buttonListEl = flyoutContainer.createDiv({
cls: 'cm-flyout-menu-buttons',
});
const triggerBtn = new ButtonComponent(flyoutContainer)
.setIcon('settings')
.setTooltip(t('tooltips.langMenu'))
.setClass('cm-flyout-trigger-btn')
.onClick(() => {
buttonListEl.toggleClass('is-open', !buttonListEl.classList.contains('is-open'));
});
triggerBtn.buttonEl.classList.add('cm-control-icon-button');
// Info Button
new ButtonComponent(buttonListEl)
.setIcon('info')
.setTooltip(t('tooltips.langInfo'))
.setClass('cm-control-icon-button')
.onClick(() => {
new LanguageInfoModal(this.app, this.plugin).open();
});
// Edit Button
new ButtonComponent(buttonListEl)
.setIcon('pencil')
.setTooltip(t('tooltips.editLang'))
.setClass('cm-control-icon-button')
.onClick(() => {
const langCode = this.plugin.settings.language;
new LanguageTranslatorModal(this.app, this.plugin, this, langCode).open();
});
// Add Button
new ButtonComponent(buttonListEl)
.setIcon('plus')
.setTooltip(t('modals.addLang.title'))
.setClass('cm-control-icon-button')
.onClick(() => {
new AddNewLanguageModal(this.app, this.plugin, this).open();
});
// RTL Button
if (isRtlCapable) {
new ButtonComponent(buttonListEl)
.setIcon('settings-2') //
.setTooltip(t('settings.languageSettingsModalTitle'))
.setClass('cm-control-icon-button')
.onClick(() => {
new LanguageSettingsModal(this.app, this.plugin).open();
});
}
// History/Restore Button for Core Languages that are customized
if (isCore && isCustom) {
const restoreBtn = new ButtonComponent(buttonListEl)
.setIcon('history')
.setTooltip(t('tooltips.restoreDefaultLang'))
.setClass('mod-warning')
.onClick(() => {
new ConfirmationModal(
this.app,
this.plugin,
t('modals.confirmation.restoreLangTitle'),
t('modals.confirmation.restoreLangDesc', CORE_LANGUAGES[langCode as LocaleCode]),
() => {
void (async () => {
if (this.plugin.settings.customLanguages) {
delete this.plugin.settings.customLanguages[langCode];
loadLanguage(this.plugin.settings);
await this.plugin.saveSettings();
this.display();
new Notice(t('notices.langRestored'));
}
})().catch((err) => {
// Short human comment in English (as you requested)
console.error('Failed to restore language:', err);
});
},
{ buttonText: t('buttons.restore'), buttonClass: 'mod-warning' },
).open();
});
restoreBtn.buttonEl.classList.add('cm-control-icon-button');
}
if (isCustom && !isCore) {
const deleteBtn = new ButtonComponent(buttonListEl)
.setIcon('trash')
.setTooltip(t('buttons.delete') + ` (${customLang.languageName})`)
.setClass('mod-warning')
.onClick(() => {
new ConfirmationModal(
this.app,
this.plugin,
t('modals.confirmation.deleteLangTitle'),
t('modals.confirmation.deleteLangDesc', customLang.languageName),
() => {
void (async () => {
if (!this.plugin.settings.customLanguages) return;
delete this.plugin.settings.customLanguages[langCode];
this.plugin.settings.language = 'en';
loadLanguage(this.plugin.settings);
await this.plugin.saveSettings();
this.display();
new Notice(t('notices.langDeleted', customLang.languageName));
})().catch((err) => {
console.error('Failed to delete custom language:', err);
});
},
{ buttonText: t('buttons.delete'), buttonClass: 'mod-warning' },
).open();
});
deleteBtn.buttonEl.classList.add('cm-control-icon-button');
}
languageSetting.addDropdown((dropdown) => {
const customLangs = this.plugin.settings.customLanguages || {};
// 1. Add Core languages FIRST
for (const code in CORE_LANGUAGES) {
// Use custom name if it exists, otherwise use core name
let displayName = CORE_LANGUAGES[code as LocaleCode];
if (customLangs[code]) {
displayName = customLangs[code].languageName;
}
dropdown.addOption(code, displayName);
}
// 2. Add purely Custom languages (that are NOT core overrides)
const customCodes = Object.keys(customLangs);
for (const code of customCodes) {
if (CORE_LANGUAGES[code as LocaleCode]) continue;
const langName = customLangs[code].languageName;
dropdown.addOption(code, langName);
}
dropdown.setValue(this.plugin.settings.language);
dropdown.onChange(async (value) => {
this.plugin.settings.language = value;
loadLanguage(this.plugin.settings);
await this.plugin.saveSettings();
this.display();
});
});
this.staticContentContainer = containerEl.createDiv({
cls: 'cm-static-sections',
});
drawProfileManager(this.staticContentContainer, this);
drawImportExport(this.staticContentContainer, this);
drawOptionsSection(this.staticContentContainer, this);
this.staticContentContainer.createEl('hr');
drawCssSnippetsUI(this.staticContentContainer, this);
containerEl.createEl('hr', { cls: 'cm-search-divider' });
this.initSearchUI(containerEl);
drawColorPickers(this.containerEl, this, themeDefaults);
containerEl.createEl('hr');
const noResultsBadge = containerEl.createDiv({
cls: 'cm-search-empty-badge is-hidden',
});
const noResultsIcon = noResultsBadge.createDiv({
cls: 'cm-search-empty-icon',
});
setIcon(noResultsIcon, 'search-x');
noResultsBadge.createDiv({
cls: 'cm-search-empty-title',
text: t('settings.noResultsFound'),
});
noResultsBadge.createDiv({
cls: 'cm-search-empty-subtitle',
text: t('settings.noResultsHint'),
});
this.noSearchResultsEl = noResultsBadge;
this.likeCardEl = drawLikePluginCard(containerEl, this);
//---Implement automatic search and scrolling---
// Make sure that the filter is applied
this._applySearchFilter();
if (this._searchState.query) {
setTimeout(() => {
// Find the first "visible" line
const firstVisibleRow = this.containerEl.querySelector<HTMLElement>(
'.cm-var-row:not(.cm-hidden)',
);
if (firstVisibleRow) {
firstVisibleRow.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, 0);
}
const scrollContainer = this.containerEl.closest<HTMLElement>('.vertical-tab-content');
if (!scrollContainer) {
console.warn('Theme Engine: Could not find scroll container.');
} else {
const debouncedScrollSave = debounce(() => {
this.plugin.settings.lastScrollPosition = scrollContainer.scrollTop;
void this.plugin.saveData(this.plugin.settings).catch((err) => {
console.error('Failed to save scroll position:', err);
});
}, 200);
this.plugin.registerDomEvent(scrollContainer, 'scroll', debouncedScrollSave);
if (!this._searchState.query && this.plugin.settings.lastScrollPosition) {
scrollContainer.scrollTo({
top: this.plugin.settings.lastScrollPosition,
behavior: 'auto',
});
}
}
containerEl.classList.remove('theme-engine-hidden');
} }
} }