From 61fa8f29add2df388cda222a3b97ff327f552f86 Mon Sep 17 00:00:00 2001 From: YazanAmmar <193124783+YazanAmmar@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:19:42 +0300 Subject: [PATCH] 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 --- eslint.config.mjs | 9 +- package-lock.json | 146 +-- package.json | 6 +- src/core/background-media.ts | 407 +++++++++ src/core/migrations.ts | 242 +++++ src/core/notice-engine.ts | 232 +++++ src/core/profile-state.ts | 133 +++ src/core/settings-store.ts | 270 ++++++ src/core/style-engine.ts | 324 +++++++ src/main.ts | 1669 ++-------------------------------- src/ui/settings/render.ts | 312 +++++++ src/ui/settings/search.ts | 339 +++++++ src/ui/settingsTab.ts | 697 +------------- 13 files changed, 2461 insertions(+), 2325 deletions(-) create mode 100644 src/core/background-media.ts create mode 100644 src/core/migrations.ts create mode 100644 src/core/notice-engine.ts create mode 100644 src/core/profile-state.ts create mode 100644 src/core/settings-store.ts create mode 100644 src/core/style-engine.ts create mode 100644 src/ui/settings/render.ts create mode 100644 src/ui/settings/search.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9b786b7..5b3dc72 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,7 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; 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"; export default tseslint.config( @@ -25,7 +25,7 @@ export default tseslint.config( { files: ["src/**/*.ts", "src/**/*.tsx"], plugins: { - "eslint-comments": eslintComments, + "@eslint-community/eslint-comments": eslintComments, }, languageOptions: { ecmaVersion: 2022, @@ -47,8 +47,9 @@ export default tseslint.config( "obsidianmd/hardcoded-config-path": "error", "obsidianmd/no-forbidden-elements": "error", "@typescript-eslint/await-thenable": "error", - "eslint-comments/require-description": "error", - "eslint-comments/no-restricted-disable": [ + "@eslint-community/eslint-comments/require-description": "error", + "@eslint-community/eslint-comments/no-unused-disable": "error", + "@eslint-community/eslint-comments/no-restricted-disable": [ "error", "obsidianmd/no-forbidden-elements", "obsidianmd/ui/sentence-case", diff --git a/package-lock.json b/package-lock.json index 335a489..edae995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", "@types/node": "^25.2.3", "@types/sortablejs": "^1.15.9", - "@typescript-eslint/utils": "^8.55.0", + "@typescript-eslint/utils": "^8.56.0", "cpy-cli": "^7.0.0", "esbuild": "^0.27.3", "esbuild-sass-plugin": "^3.6.0", @@ -27,7 +27,7 @@ "prettier": "^3.8.1", "sass": "^1.97.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.55.0" + "typescript-eslint": "^8.56.0" } }, "node_modules/@bufbuild/protobuf": { @@ -1311,17 +1311,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", - "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/type-utils": "8.55.0", - "@typescript-eslint/utils": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -1334,8 +1334,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.55.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -1350,16 +1350,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", - "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "engines": { @@ -1370,19 +1370,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "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" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", - "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.55.0", - "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, "engines": { @@ -1397,14 +1397,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", - "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1415,9 +1415,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", - "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, "license": "MIT", "engines": { @@ -1432,15 +1432,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", - "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -1452,14 +1452,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "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" } }, "node_modules/@typescript-eslint/types": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", - "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, "license": "MIT", "engines": { @@ -1471,16 +1471,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", - "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.55.0", - "@typescript-eslint/tsconfig-utils": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -1499,16 +1499,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", - "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0" + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1518,19 +1518,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "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" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", - "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "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": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6183,16 +6183,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", - "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.55.0", - "@typescript-eslint/parser": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0" + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6202,7 +6202,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "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" } }, diff --git a/package.json b/package.json index 1324083..1c45207 100644 --- a/package.json +++ b/package.json @@ -19,20 +19,20 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", "@types/node": "^25.2.3", "@types/sortablejs": "^1.15.9", - "@typescript-eslint/utils": "^8.55.0", + "@typescript-eslint/utils": "^8.56.0", "cpy-cli": "^7.0.0", "esbuild": "^0.27.3", "esbuild-sass-plugin": "^3.6.0", "eslint": "^9.39.2", - "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", "eslint-plugin-obsidianmd": "^0.1.9", "globals": "^17.3.0", "obsidian": "^1.12.0", "prettier": "^3.8.1", "sass": "^1.97.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.55.0" + "typescript-eslint": "^8.56.0" } } diff --git a/src/core/background-media.ts b/src/core/background-media.ts new file mode 100644 index 0000000..7663266 --- /dev/null +++ b/src/core/background-media.ts @@ -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 => { + 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 => { + 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 => { + 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('#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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; + } +}; diff --git a/src/core/migrations.ts b/src/core/migrations.ts new file mode 100644 index 0000000..5a16bf3 --- /dev/null +++ b/src/core/migrations.ts @@ -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 => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +const normalizeHotkeyArray = (value: unknown): HotkeyEntry[] => { + const normalized: HotkeyEntry[] = []; + const seen = new Set(); + + 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(); + + 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 => { + 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 = { ...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 => { + 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 => { + 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; + } +}; diff --git a/src/core/notice-engine.ts b/src/core/notice-engine.ts new file mode 100644 index 0000000..a6e28b0 --- /dev/null +++ b/src/core/notice-engine.ts @@ -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('.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( + '[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')); +}; diff --git a/src/core/profile-state.ts b/src/core/profile-state.ts new file mode 100644 index 0000000..a85b0d8 --- /dev/null +++ b/src/core/profile-state.ts @@ -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; + customCss?: string; + snippets?: Snippet[]; + noticeRules?: { + text: NoticeRule[]; + background: NoticeRule[]; + }; +}; + +export const pinProfileSnapshot = async ( + plugin: ThemeEngine, + profileName: string, +): Promise => { + 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 => { + 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; +}; diff --git a/src/core/settings-store.ts b/src/core/settings-store.ts new file mode 100644 index 0000000..ac2d476 --- /dev/null +++ b/src/core/settings-store.ts @@ -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 => { + 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; + 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 => { + 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 => { + 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> => { + console.debug('Theme Engine: Capturing current computed styles...'); + + plugin.clearStyles(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + const capturedVars: Record = {}; + 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> => { + 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; +}; diff --git a/src/core/style-engine.ts b/src/core/style-engine.ts new file mode 100644 index 0000000..d505628 --- /dev/null +++ b/src/core/style-engine.ts @@ -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) => 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 => { + 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 => { + 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(); +}; diff --git a/src/main.ts b/src/main.ts index b8c5725..ead292e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,27 +2,64 @@ * Theme Engine - Obsidian Plugin * Version: 2.0.0 * Author: Yazan Ammar (GitHub : https://github.com/YazanAmmar ) - * Description: Provides a comprehensive UI to control all Obsidian CSS color variables directly, + * Description: Provides a comprehensive UI to control all Obsidian CSS variables directly, * removing the need for Force Mode and expanding customization options. */ -import { ButtonComponent, moment, Notice, Plugin, requestUrl } from 'obsidian'; -import { PLUGIN_COMMAND_SUFFIXES, registerCommands } from './commands'; -import { DEFAULT_SETTINGS, DEFAULT_VARS, BUILT_IN_PROFILES_VARS } from './constants'; -import { initializeT, t } from './i18n/strings'; -import { NoticeRule, PluginSettings, Profile, Snippet } from './types'; -import { ThemeEngineSettingTab } from './ui/settingsTab'; +import { moment, Notice, Plugin } from 'obsidian'; +import { registerCommands } from './commands'; import { - flattenVars, - findNextAvailablePath, - maybeConvertToJpg, - isIconizeEnabled, - convertColorToHex, -} from './utils'; -import { FileConflictModal } from './ui/modals'; - -type StylableElement = Element & { - setCssProps: (props: Record) => void; -}; + applyBackgroundMedia as applyBackgroundMediaCore, + applyTransparencyToVars as applyTransparencyToVarsCore, + clearBackgroundMedia, + ensureBackgroundsFolderExists, + removeBackgroundMediaByPath as removeBackgroundMediaByPathCore, + renameBackgroundMedia as renameBackgroundMediaCore, + restoreDefaultBackgroundVars, + selectBackgroundMedia as selectBackgroundMediaCore, + setBackgroundMedia as setBackgroundMediaCore, + setBackgroundMediaFromUrl as setBackgroundMediaFromUrlCore, +} from './core/background-media'; +import { + CURRENT_PLUGIN_ID, + LEGACY_PLUGIN_IDS, + migrateCommunityPluginListIfNeeded, + migrateLegacyHotkeysIfNeeded, + migrateLegacyPluginDataIfNeeded, +} from './core/migrations'; +import { + pinProfileSnapshot as pinProfileSnapshotCore, + pushCssHistory as pushCssHistoryCore, + redoCssHistory as redoCssHistoryCore, + resetProfileToPinned as resetProfileToPinnedCore, + undoCssHistory as undoCssHistoryCore, +} from './core/profile-state'; +import { + captureCurrentComputedVars as captureCurrentComputedVarsCore, + getThemeDefaults as getThemeDefaultsCore, + loadSettings as loadSettingsCore, + resetPluginData as resetPluginDataCore, + saveSettings as saveSettingsCore, +} from './core/settings-store'; +import { + applyCssSnippets as applyCssSnippetsCore, + applyCustomCssForProfile as applyCustomCssForProfileCore, + applyPendingNow as applyPendingNowCore, + applyStyles as applyStylesCore, + clearStyles as clearStylesCore, + forceIconizeColors as forceIconizeColorsCore, + refreshOpenGraphViews as refreshOpenGraphViewsCore, + removeCssSnippets as removeCssSnippetsCore, + removeInjectedCustomCss as removeInjectedCustomCssCore, + removeOrphanedIconizeElements as removeOrphanedIconizeElementsCore, +} from './core/style-engine'; +import { + processNotice as processNoticeCore, + updateNoticeStyles as updateNoticeStylesCore, +} from './core/notice-engine'; +import { initializeT, t } from './i18n/strings'; +import { NoticeRule, PluginSettings } from './types'; +import { ThemeEngineSettingTab } from './ui/settingsTab'; +import { isIconizeEnabled } from './utils'; type AppWithSettingsManager = { setting: { @@ -31,47 +68,6 @@ type AppWithSettingsManager = { }; }; -type AppWithCommands = { - commands: { - executeCommandById: (commandId: string) => void; - }; -}; - -type VaultWithConfigGetter = { - getConfig: (key: string) => string; -}; - -type ReloadableView = { - reload?: () => void; -}; - -type LegacyPinnedSnapshot = { - vars?: Record; - customCss?: string; - snippets?: Snippet[]; - noticeRules?: { - text: NoticeRule[]; - background: NoticeRule[]; - }; -}; - -type LegacySnippetData = { - css?: string; - enabled?: boolean; -}; - -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; -const CURRENT_PLUGIN_ID = 'theme-engine'; - export default class ThemeEngine extends Plugin { settings: PluginSettings; iconizeWatcherInterval: number | null = null; @@ -100,7 +96,7 @@ export default class ThemeEngine extends Plugin { const pendingKeys = Object.keys(this.pendingVarUpdates); if (pendingKeys.length === 0) return; - // Optimization: Check specifically for Iconize var to avoid expensive DOM updates later + // Check specifically for Iconize var to avoid expensive DOM updates later const iconizeUpdateNeeded = pendingKeys.includes('--iconize-icon-color'); for (const varName of pendingKeys) { @@ -123,7 +119,7 @@ export default class ThemeEngine extends Plugin { }, intervalMs); } - // New method to stop the update loop + // Stop the update loop stopColorUpdateLoop() { if (this.colorUpdateInterval) { window.clearInterval(this.colorUpdateInterval); @@ -131,239 +127,25 @@ export default class ThemeEngine extends Plugin { } } - // New method to easily restart the loop when settings change + // Restart the loop when settings change restartColorUpdateLoop() { this.stopColorUpdateLoop(); this.startColorUpdateLoop(); } - private isObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); - } - - private normalizeHotkeyArray(value: unknown): Array<{ modifiers: string[]; key: string }> { - const normalized: Array<{ modifiers: string[]; key: string }> = []; - const seen = new Set(); - - if (!Array.isArray(value)) { - return normalized; - } - - for (const item of value) { - if (!this.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; - } - - private mergeHotkeyArrays( - currentValue: unknown, - legacyValue: unknown, - ): Array<{ modifiers: string[]; key: string }> { - const merged = [ - ...this.normalizeHotkeyArray(currentValue), - ...this.normalizeHotkeyArray(legacyValue), - ]; - const deduped: Array<{ modifiers: string[]; key: string }> = []; - const seen = new Set(); - - for (const entry of merged) { - const signature = JSON.stringify(entry); - if (seen.has(signature)) continue; - seen.add(signature); - deduped.push(entry); - } - - return deduped; - } - - private async writeJsonWithBackup( - path: string, - backupPath: string, - data: unknown, - ): Promise { - const adapter = this.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)); - } - private async migrateLegacyPluginDataIfNeeded(): Promise<{ migrated: boolean; sourcePath?: string; }> { - let currentData: unknown = null; - - try { - currentData = await this.loadData(); - } catch (error) { - console.warn('Theme Engine: Failed to read current plugin data before migration.', error); - } - - if (this.isObjectRecord(currentData) && Object.keys(currentData).length > 0) { - return { migrated: false }; - } - - const adapter = this.app.vault.adapter; - const pluginsDir = `${this.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 (!this.isObjectRecord(parsed)) { - console.warn( - `Theme Engine: Legacy data at "${sourcePath}" is not an object. Skipping migration source.`, - ); - continue; - } - - const migratedSettings: Record = { ...parsed }; - migratedSettings.idMigration = { - from: legacyPluginId, - to: CURRENT_PLUGIN_ID, - at: new Date().toISOString(), - sourcePath, - }; - - await this.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 }; + return migrateLegacyPluginDataIfNeeded(this); } private async migrateLegacyHotkeysIfNeeded(): Promise { - const adapter = this.app.vault.adapter; - const configDir = this.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 (!this.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 = this.normalizeHotkeyArray(currentValue); - let merged = normalizedCurrent; - - for (const legacyCommandPluginId of LEGACY_COMMAND_PLUGIN_IDS) { - const oldCommandId = `${legacyCommandPluginId}:${suffix}`; - const legacyValue = hotkeyMap[oldCommandId]; - - if (typeof legacyValue === 'undefined') continue; - - merged = this.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 this.writeJsonWithBackup(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; - } + return migrateLegacyHotkeysIfNeeded(this); } private async migrateCommunityPluginListIfNeeded(): Promise { - const adapter = this.app.vault.adapter; - const configDir = this.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 this.writeJsonWithBackup(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; - } + return migrateCommunityPluginListIfNeeded(this); } private supportsRuntimeStyleSheets(): boolean { @@ -394,7 +176,7 @@ export default class ThemeEngine extends Plugin { return styleSheet; } - private setRuntimeStyle(styleId: string, cssText: string): void { + setRuntimeStyle(styleId: string, cssText: string): void { const styleSheet = this.getOrCreateRuntimeStyleSheet(styleId); if (!styleSheet) return; @@ -405,7 +187,7 @@ export default class ThemeEngine extends Plugin { } } - private clearRuntimeStyle(styleId: string): void { + clearRuntimeStyle(styleId: string): void { const styleSheet = this.runtimeStyleSheets[styleId]; if (!styleSheet) return; @@ -420,104 +202,16 @@ export default class ThemeEngine extends Plugin { // Removes background image CSS variable and body classes. _clearBackgroundMedia() { - document.body.setCssProps({ '--cm-background-image': null }); - document.body.classList.remove('cm-workspace-background-active'); - document.body.classList.remove('cm-settings-background-active'); - - // Delete any old video - const oldVideo = document.getElementById('cm-background-video'); - if (oldVideo) oldVideo.remove(); + clearBackgroundMedia(); } async applyTransparencyToVars(): Promise { - const profile = this.settings.profiles?.[this.settings.activeProfile]; - if (!profile) return false; - - const activeProfileName = this.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 (this.settings.colorUpdateFPS > 0) { - this.pendingVarUpdates[varName] = 'transparent'; - } - } - } - } - - if (settingsChanged) { - await this.saveData(this.settings); - if (this.settingTabInstance && this.settingTabInstance.containerEl.offsetHeight > 0) { - this.settingTabInstance.display(); - } - } - return settingsChanged; + return applyTransparencyToVarsCore(this); } // Applies the current profile's background image and transparency settings. async applyBackgroundMedia() { - const profile = this.settings.profiles?.[this.settings.activeProfile]; - - // 1. Clean any old background (image or video) - this._clearBackgroundMedia(); - - // 2. Exit if the add-on is off, there is no profile, or the background is off - if (!profile || profile.backgroundEnabled === false || !profile.backgroundPath) { - if (profile && profile.backgroundEnabled === false) { - await this._restoreDefaultBackgroundVars(); - } - return; - } - - // 3. Apply transparency - await this.applyTransparencyToVars(); - - const path = profile.backgroundPath; - const type = profile.backgroundType; - if (type === 'image') { - const imageUrl = this.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 = this.app.vault.adapter.getResourcePath(path); - - let videoEl = document.querySelector('#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'); - } + await applyBackgroundMediaCore(this); } /** @@ -525,148 +219,19 @@ export default class ThemeEngine extends Plugin { * @returns True if settings were changed and saved, false otherwise. */ async _restoreDefaultBackgroundVars(): Promise { - const profile = this.settings.profiles?.[this.settings.activeProfile]; - if (!profile) return false; - - const activeProfileName = this.settings.activeProfile; - const profileOriginalVars = - BUILT_IN_PROFILES_VARS[activeProfileName as keyof typeof BUILT_IN_PROFILES_VARS]; - const baseVars = profileOriginalVars || BUILT_IN_PROFILES_VARS.Default; - - // List of variables to check and potentially restore - const varsToRestore = [ - '--background-primary', - '--background-secondary', - '--background-modifier-border', - '--titlebar-background-focused', - '--background-modifier-hover', - ]; - - let settingsRestored = false; - for (const varName of varsToRestore) { - // Only restore if currently 'transparent' - if (profile.vars[varName] === 'transparent') { - const defaultValue = baseVars[varName as keyof typeof baseVars]; - if (defaultValue) { - profile.vars[varName] = defaultValue; - settingsRestored = true; - // Update pending immediately if live preview is on - if (this.settings.colorUpdateFPS > 0) { - this.pendingVarUpdates[varName] = defaultValue; - } - } - } - } - - // Save and update UI only if changes were made - if (settingsRestored) { - await this.saveData(this.settings); // Persist - // Refresh settings UI if visible - if (this.settingTabInstance && this.settingTabInstance.containerEl.offsetHeight > 0) { - this.settingTabInstance.display(); - } - if (this.settings.colorUpdateFPS === 0) { - this.applyPendingNow(); // Apply changes immediately if live preview is off - } - return true; - } - - return false; + return restoreDefaultBackgroundVars(this); } /** * Ensures the global .obsidian/backgrounds folder exists on startup. */ async _ensureBackgroundsFolderExists(): Promise { - const backgroundsPath = `${this.app.vault.configDir}/backgrounds`; - try { - // Check if the folder does NOT exist - if (!(await this.app.vault.adapter.exists(backgroundsPath))) { - // Create the folder if it's missing - await this.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); - } + await ensureBackgroundsFolderExists(this); } // This function now deletes the file AND clears the path from ALL profiles using it. async removeBackgroundMediaByPath(pathToDelete: string) { - if (!pathToDelete) return; - - const varsToRestore = [ - '--background-primary', - '--background-secondary', - '--background-modifier-border', - '--titlebar-background-focused', - '--background-modifier-hover', - ]; - - let settingsChanged = false; - - // 1. Iterate through ALL profiles to find who is using this image - for (const profileName in this.settings.profiles) { - const profile = this.settings.profiles[profileName]; - - if (profile.backgroundPath === pathToDelete) { - // Clear the path - profile.backgroundPath = ''; - profile.backgroundType = undefined; - // Disable background automatically to prevent issues - profile.backgroundEnabled = false; - - // Restore colors for THIS profile (Active or Inactive) - 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 { - // If no default exists, remove the var to let theme handle it - delete profile.vars[varName]; - } - } - } - - settingsChanged = true; - } - } - - // 2. Try to delete the actual file - try { - if (await this.app.vault.adapter.exists(pathToDelete)) { - await this.app.vault.adapter.remove(pathToDelete); - } - } catch (error) { - console.warn(`Theme Engine: Could not delete background file '${pathToDelete}'.`, error); - } - - if (settingsChanged) { - await this.saveSettings(); - - // If the active profile was affected, we need to apply changes immediately - // We use pending updates to make it smooth if live preview is on - const activeProfile = this.settings.profiles[this.settings.activeProfile]; - if (activeProfile.backgroundPath === '') { - // Re-apply pending updates for the active profile just in case - for (const varName of varsToRestore) { - const val = activeProfile.vars[varName]; - if (val) this.pendingVarUpdates[varName] = val; - } - this.applyPendingNow(); - } - } - - // 3. Refresh settings tab UI - if (this.settingTabInstance) { - this.settingTabInstance.display(); - } + await removeBackgroundMediaByPathCore(this, pathToDelete); } // Adds or replaces a background image file in the global folder. @@ -675,246 +240,36 @@ export default class ThemeEngine extends Plugin { fileName: string, conflictChoice: 'replace' | 'keep' | 'prompt' = 'prompt', ) { - const activeProfile = this.settings.profiles?.[this.settings.activeProfile]; - if (!activeProfile) return; - - const { arrayBuffer: finalArrayBuffer, fileName: finalFileName } = await maybeConvertToJpg( - activeProfile, - arrayBuffer, - fileName, - ); - - const fileExt = finalFileName.split('.').pop()?.toLowerCase(); - const mediaType: 'image' | 'video' = - fileExt === 'mp4' || fileExt === 'webm' ? 'video' : 'image'; - - const backgroundsPath = `${this.app.vault.configDir}/backgrounds`; - - let targetPath = `${backgroundsPath}/${finalFileName}`; // Use finalFileName - - // 1. Ensure folder exists - try { - if (!(await this.app.vault.adapter.exists(backgroundsPath))) { - await this.app.vault.adapter.mkdir(backgroundsPath); - } - - const fileExists = await this.app.vault.adapter.exists(targetPath); - - // 2. Resolve conflicts if file exists - if (fileExists && conflictChoice === 'prompt') { - new FileConflictModal(this.app, this, finalArrayBuffer, finalFileName, (choice) => { - // run async logic safely - void (async () => { - await this.setBackgroundMedia(finalArrayBuffer, finalFileName, choice); - })().catch((err) => { - console.error('Failed to set background media:', err); - }); - }).open(); - return; - } - - if (fileExists && conflictChoice === 'keep') { - targetPath = await findNextAvailablePath(this.app.vault.adapter, targetPath); - } - if (fileExists && conflictChoice === 'replace') { - await this.app.vault.adapter.remove(targetPath); - } - - // 3. Save image data - await this.app.vault.createBinary(targetPath, finalArrayBuffer); - - const oldImagePath = activeProfile.backgroundPath; - - // 4. Update settings - activeProfile.backgroundPath = targetPath; - activeProfile.backgroundType = mediaType; - - // 5. Save settings (this will trigger applyBackgroundMedia) - await this.saveSettings(); - new Notice(t('notices.bgSet')); - - // 6. Cleanup previous file if replacing original name and paths differ - if ( - oldImagePath && - oldImagePath !== targetPath && - conflictChoice === 'replace' // Only delete if 'replace' - ) { - if (await this.app.vault.adapter.exists(oldImagePath)) { - await this.app.vault.adapter.remove(oldImagePath); - } - } - - // 7. Refresh settings tab UI - if (this.settingTabInstance) { - this.settingTabInstance.display(); - } - } catch (error) { - new Notice(t('notices.backgroundLoadError')); - console.error('Theme Engine: Error setting background media:', error); - } + await setBackgroundMediaCore(this, arrayBuffer, fileName, conflictChoice); } applyCustomCssForProfile(profileName: string) { - try { - if (!profileName) profileName = this.settings.activeProfile; - const profile = this.settings.profiles?.[profileName]; - this.removeInjectedCustomCss(); - - if (!profile || !profile.isCssProfile || !profile.customCss) { - return; - } - - this.setRuntimeStyle('cm-custom-css-for-profile', profile.customCss); - } catch (e) { - console.warn('applyCustomCssForProfile failed', e); - } + applyCustomCssForProfileCore(this, profileName); } removeInjectedCustomCss() { - try { - this.clearRuntimeStyle('cm-custom-css-for-profile'); - const oldStyle = document.getElementById('cm-custom-css-for-profile'); - if (oldStyle) oldStyle.remove(); - } catch (e) { - console.warn(e); - } + removeInjectedCustomCssCore(this); } applyCssSnippets() { - this.removeCssSnippets(); - const activeProfile = this.settings.profiles[this.settings.activeProfile]; - if (!activeProfile) return; - - const globalSnippets = this.settings.globalSnippets || []; - const profileSnippets = Array.isArray(activeProfile.snippets) ? activeProfile.snippets : []; - - const allSnippets = [...globalSnippets, ...profileSnippets].filter(Boolean); - - const enabledCss = allSnippets - .filter((s) => s.enabled && s.css) - .map((s) => { - const upgradedCss = s.css.replace(/\bbody\s*(?=\{)/g, 'body.theme-dark, body.theme-light'); - return `/* Snippet: ${s.name} ${s.isGlobal ? '(Global)' : ''} */\n${upgradedCss}`; - }) - .join('\n\n'); - - if (enabledCss) { - this.setRuntimeStyle('cm-css-snippets', enabledCss); - } + applyCssSnippetsCore(this); } removeCssSnippets() { - this.clearRuntimeStyle('cm-css-snippets'); - const el = document.getElementById('cm-css-snippets'); - if (el) el.remove(); + removeCssSnippetsCore(this); } - // New method to apply pending changes instantly + // Apply pending changes instantly applyPendingNow() { - try { - const pending = this.pendingVarUpdates || {}; - const keys = Object.keys(pending); - if (keys.length === 0) { - // If nothing is pending, still trigger a repaint for safety - this.app.workspace.trigger('css-change'); - window.dispatchEvent(new Event('resize')); - return; - } - - // Apply all pending CSS properties - for (const k of keys) { - const value = pending[k] ?? null; - document.body.setCssProps({ [k]: value }); - } - - // Clear pending updates - this.pendingVarUpdates = {}; - - // Notify Obsidian and other components to update - this.app.workspace.trigger('css-change'); - this.forceIconizeColors(); - - // Force graph view to repaint by dispatching a resize event - window.dispatchEvent(new Event('resize')); - } catch (e) { - console.error('Theme Engine: applyPendingNow failed', e); - } + applyPendingNowCore(this); } pinProfileSnapshot(profileName: string) { - if (!profileName) profileName = this.settings.activeProfile; - this.settings.pinnedSnapshots = this.settings.pinnedSnapshots || {}; - const profile = this.settings.profiles?.[profileName]; - 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: [] }, - }), - ); - - this.settings.pinnedSnapshots[profileName] = { - pinnedAt: new Date().toISOString(), - profile: snapshotProfile, - }; - return this.saveSettings(); + return pinProfileSnapshotCore(this, profileName); } async resetProfileToPinned(profileName: string): Promise { - if (!profileName) profileName = this.settings.activeProfile; - const snap = this.settings.pinnedSnapshots?.[profileName]; - if (!snap) { - new Notice(t('notices.noPinnedSnapshot')); - return; - } - - const activeProfile = this.settings.profiles[profileName]; - 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((k) => { - this.pendingVarUpdates[k] = pinnedProfile.vars[k]; - }); - - await this.saveSettings(); - this.applyPendingNow(); + await resetProfileToPinnedCore(this, profileName); } resetIconizeWatcher() { @@ -1089,341 +444,31 @@ export default class ThemeEngine extends Plugin { } async refreshOpenGraphViews() { - const graphLeaves = this.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 }, - }); - } + await refreshOpenGraphViewsCore(this); } forceIconizeColors() { - // read the *computed* CSS var first (this covers live-preview via pendingVarUpdates) - let computedIconizeColor = null; - try { - const cssVal = getComputedStyle(document.body).getPropertyValue('--iconize-icon-color'); - if (cssVal) computedIconizeColor = cssVal.trim(); - } catch (e) { - console.warn('Theme Engine: failed to read computed --iconize-icon-color', e); - computedIconizeColor = null; - } - - // fallback to stored profile value if computed is empty - const storedIconizeColor = - this.settings.profiles?.[this.settings.activeProfile]?.vars?.['--iconize-icon-color']; - - const iconizeColor = this.settings.overrideIconizeColors - ? computedIconizeColor || storedIconizeColor || null - : null; - - // Iterate over elements that Iconize marks (keep the original safe logic) - 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) { - // remove inline overrides to let theme/defaults show - el.setCssProps({ - fill: null, - stroke: null, - }); - return; - } - - const originalFill = el.getAttribute('fill'); - const originalStroke = el.getAttribute('stroke'); - - // apply with !important so plugin/theme inline styles are overridden - if (originalFill && originalFill !== 'none' && !originalFill.startsWith('url(')) { - el.setCssProps({ fill: iconizeColor }); - } - - if (originalStroke && originalStroke !== 'none') { - el.setCssProps({ stroke: iconizeColor }); - } - }); - }); + forceIconizeColorsCore(this); } removeOrphanedIconizeElements() { - const iconizeInstalled = isIconizeEnabled(this.app); - - // We only proceed if Iconize is actually installed. If so, we do nothing. - if (iconizeInstalled) { - return; - } - - // If not installed, check if the override setting is still on. - let settingsChanged = false; - if (this.settings.overrideIconizeColors === true) { - console.debug('Theme Engine: Iconize plugin not found. Disabling override setting.'); - this.settings.overrideIconizeColors = false; - settingsChanged = true; - } - - // Find all elements with the .iconize-icon class and check if they have content. - 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 this.saveSettings().catch((err) => { - console.error('Failed to save settings after removing Iconize leftovers:', err); - }); - } + removeOrphanedIconizeElementsCore(this); } async applyStyles() { - this.removeOrphanedIconizeElements(); - this.clearStyles(); - if (!this.settings.pluginEnabled) { - this.removeInjectedCustomCss(); - return; - } - - const profile = this.settings.profiles[this.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 ')} - }`; - - this.setRuntimeStyle('cm-profile-variables', cssString); - } - - this.forceIconizeColors(); - setTimeout(() => this.forceIconizeColors(), 100); - - const themeType = profile.themeType || 'auto'; - - // Handle theme toggle (Dark/Light/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 = this.app.vault as typeof this.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'); - } - } - this.applyCustomCssForProfile(this.settings.activeProfile); - this.applyCssSnippets(); - - // Add/Remove body class for RTL notices - const langCode = this.settings.language; - const customLang = this.settings.customLanguages?.[langCode]; - const isCoreRtlLang = langCode === 'ar' || langCode === 'fa'; - const isCustomRtlLang = customLang?.isRtl === true; - const isRtlEnabled = this.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 this.applyBackgroundMedia(); - this.app.workspace.trigger('css-change'); - window.dispatchEvent(new Event('resize')); - - setTimeout(() => { - this.app.workspace.trigger('css-change'); - const graphLeaves = this.app.workspace.getLeavesOfType('graph'); - graphLeaves.forEach((leaf) => { - const view = leaf.view as ReloadableView | null; - if (view?.reload) { - view.reload(); - } - }); - }, 300); + await applyStylesCore(this); } clearStyles() { - this.clearRuntimeStyle('cm-profile-variables'); - this.clearRuntimeStyle('cm-dynamic-notice-styles'); - - const profileStyleEl = document.getElementById('cm-profile-variables'); - if (profileStyleEl) { - profileStyleEl.remove(); - } - - this.removeCssSnippets(); - - const allVars = new Set(); - Object.keys(flattenVars(DEFAULT_VARS)).forEach((key) => allVars.add(key)); - for (const profileName in this.settings.profiles) { - const profile = this.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(); - } - this.app.workspace.trigger('css-change'); - document.body.classList.remove('theme-engine-rtl'); - this._clearBackgroundMedia(); + clearStylesCore(this); } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - - let migrationNeeded = false; - if (this.settings.noticeRules && Object.keys(this.settings.profiles || {}).length > 0) { - console.debug('Theme Engine: Detected old global notice rules. Starting migration...'); - for (const profileName in this.settings.profiles) { - const profile = this.settings.profiles[profileName]; - if (!profile.noticeRules) { - profile.noticeRules = JSON.parse(JSON.stringify(this.settings.noticeRules)); - } - } - delete this.settings.noticeRules; - migrationNeeded = true; - } - - for (const profileName in this.settings.profiles) { - const profile = this.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 this.saveData(this.settings); - } - - if (!this.settings.installDate) { - this.settings.installDate = new Date().toISOString(); - - // Check the current Obsidian theme to set a smart default profile. - const isDarkMode = document.body.classList.contains('theme-dark'); - if (isDarkMode) { - this.settings.activeProfile = 'Default'; - } else { - this.settings.activeProfile = 'Citrus Zest'; - } - - await this.saveData(this.settings); - } - if (!this.settings.pinnedSnapshots) { - this.settings.pinnedSnapshots = {}; - } - - let snippetsMigrationNeeded = false; - for (const profileName in this.settings.profiles) { - const profile = this.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; - const snippetsArray = Object.entries(legacySnippets).map(([name, data]) => ({ - id: `snippet-${Date.now()}-${Math.random()}`, - name: 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 this.saveData(this.settings); - } - - let profileMigrationNeeded = false; - for (const profileName in this.settings.profiles) { - const profile = this.settings.profiles[profileName]; - // If background exists but enabled status is not set, default it to true - if (profile && profile.backgroundPath && typeof profile.backgroundEnabled === 'undefined') { - profile.backgroundEnabled = true; - profileMigrationNeeded = true; - } - // If background doesn't exist, ensure enabled is false or undefined - 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 this.saveData(this.settings); // Save changes if migration happened - } + await loadSettingsCore(this); } async saveSettings() { - await this.saveData(this.settings); - await this.applyStyles(); - await this.refreshOpenGraphViews(); - this.app.workspace.trigger('css-change'); + await saveSettingsCore(this); } async resetPluginData(options: { @@ -1433,367 +478,15 @@ export default class ThemeEngine extends Plugin { deleteBackgrounds: boolean; deleteLanguages?: boolean; }) { - const oldInstallDate = this.settings.installDate; - - // Deep clone defaults to ensure a clean slate before merging preserved data - const newSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as PluginSettings; - - // 1. Preserve Settings (if not deleting) - if (!options.deleteSettings) { - newSettings.language = this.settings.language; - newSettings.useRtlLayout = this.settings.useRtlLayout; - newSettings.overrideIconizeColors = this.settings.overrideIconizeColors; - newSettings.cleanupInterval = this.settings.cleanupInterval; - newSettings.colorUpdateFPS = this.settings.colorUpdateFPS; - newSettings.lastSearchQuery = this.settings.lastSearchQuery; - newSettings.lastScrollPosition = this.settings.lastScrollPosition; - newSettings.advancedResetOptions = this.settings.advancedResetOptions; - } - - // 2. Preserve Profiles - if (!options.deleteProfiles) { - newSettings.profiles = this.settings.profiles; - newSettings.activeProfile = this.settings.activeProfile; - newSettings.pinnedSnapshots = this.settings.pinnedSnapshots; - } - - // 3. Preserve Snippets - if (!options.deleteSnippets) { - newSettings.globalSnippets = this.settings.globalSnippets; - } - - // 4. Preserve Languages - if (!options.deleteLanguages) { - newSettings.customLanguages = this.settings.customLanguages; - } - - // Edge Case: Deleting snippets but keeping profiles -> clear snippets from preserved profiles - if (options.deleteSnippets && !options.deleteProfiles) { - for (const profileName in newSettings.profiles) { - if (newSettings.profiles[profileName]) { - newSettings.profiles[profileName].snippets = []; - } - } - } - - newSettings.installDate = oldInstallDate; - - // Reset specific settings if requested - if (options.deleteSettings) { - newSettings.advancedResetOptions = this.settings.advancedResetOptions; - newSettings.language = 'auto'; - } - - this.settings = newSettings; - await this.saveData(this.settings); - - console.debug('Theme Engine: Selective data reset complete.', options); - - // Handle File System operations (Backgrounds) - if (options.deleteBackgrounds) { - const backgroundsPath = `${this.app.vault.configDir}/backgrounds`; - try { - if (await this.app.vault.adapter.exists(backgroundsPath)) { - console.debug('Theme Engine: Deleting backgrounds folder...'); - await this.app.vault.adapter.rmdir(backgroundsPath, true); - } - - // If profiles are kept but backgrounds deleted, remove references from profiles - if (!options.deleteProfiles) { - for (const profileName in this.settings.profiles) { - const profile = this.settings.profiles[profileName]; - if (profile) { - profile.backgroundPath = undefined; - profile.backgroundType = undefined; - } - } - await this.saveData(this.settings); - } - } catch (folderError) { - console.error(`Theme Engine: Error deleting backgrounds: ${folderError.message}`); - new Notice(t('notices.deleteBackgroundsError', folderError.message)); - } - } - - // UI Feedback & Reload - 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 = this.app as typeof this.app & AppWithCommands; - appWithCommands.commands.executeCommandById('app:reload'); - }); + await resetPluginDataCore(this, options); } processNotice(el: HTMLElement) { - // Prevent processing the same element multiple times - if (!el || !el.classList || el.dataset.cmProcessed === 'true') return; - - // Extract text content (handle test keywords if present) - let noticeText = (el.textContent || '').toLowerCase(); - const testKeywordEl = el.querySelector('.cm-test-keyword'); - if (testKeywordEl) { - noticeText = (testKeywordEl.textContent || '').toLowerCase(); - } - - el.dataset.cmProcessed = 'true'; - - try { - const settings = this.settings; - const activeProfile = settings.profiles[settings.activeProfile]; - if (!activeProfile) return; - - const liveRules = this.liveNoticeRules; - const liveRuleType = this.liveNoticeRuleType; - - // --- 1. Background Rules (Base Style) --- - const bgRules: NoticeRule[] = - liveRuleType === 'background' && liveRules - ? liveRules - : activeProfile?.noticeRules?.background || []; - - let finalBgColor = activeProfile.vars['--cm-notice-bg-default']; - - for (const rule of bgRules) { - if (!rule.keywords?.trim()) continue; - 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 { - // Optimize: Only process if keyword array isn't empty - const keywordArray = keywords - .split(',') - .map((k: string) => k.trim()) - .filter(Boolean); - if (keywordArray.length > 0) { - const escaped = keywordArray.map((k: string) => - k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), - ); - // Enforce whole-word matching to avoid partial false positives - if (new RegExp(`\\b(${escaped.join('|')})\\b`, 'i').test(noticeText)) { - match = true; - } - } - } - - if (match) { - finalBgColor = rule.color; - break; // Priority: First match wins - } - } - - if (finalBgColor) { - el.dataset.cmNoticeBg = finalBgColor; - } - - // --- 2. Text Rules (Split: Base Color vs. DOM Highlights) --- - const textRules: NoticeRule[] = - liveRuleType === 'text' && liveRules ? liveRules : activeProfile?.noticeRules?.text || []; - - let finalTextColor = activeProfile.vars['--cm-notice-text-default']; - const highlightRules: NoticeRule[] = []; - const fullColorRules: NoticeRule[] = []; - - // Pre-sort rules to avoid multiple iterations later - for (const rule of textRules) { - if (rule.highlightOnly) { - highlightRules.push(rule); - } else { - fullColorRules.push(rule); - } - } - - // 2a. Apply Base Text Color - for (const rule of fullColorRules) { - if (!rule.keywords?.trim()) continue; - 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; - } - } - } - if (match) { - finalTextColor = rule.color; - break; - } - } - - if (finalTextColor) { - el.dataset.cmNoticeText = finalTextColor; - } - - // 2b. Apply Keyword Highlights (DOM Manipulation) - if (highlightRules.length > 0) { - const textNodes: Node[] = []; - - // Use TreeWalker to safely traverse text nodes without breaking nested HTML - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { - acceptNode: (node) => { - return node.parentElement?.classList.contains('cm-keyword-highlight') - ? NodeFilter.FILTER_REJECT // Skip already highlighted - : 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; - - // Collect all matches from all highlight rules - 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 (e) { - console.warn('Theme Engine: Invalid Regex in notice rule', e); - } - } 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; - - // Sort and filter matches to prevent overlapping spans - 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; - - // Rebuild DOM fragment with highlights - 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); - } - } - - this.updateNoticeStyles(); - } catch (e) { - console.warn('Theme Engine: processNotice failed', e); - } + processNoticeCore(this, el); } updateNoticeStyles() { - const styleId = 'cm-dynamic-notice-styles'; - - const notices = document.querySelectorAll( - '[data-cm-notice-bg], [data-cm-notice-text]', - ); - if (notices.length === 0) { - this.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); - }); - - this.setRuntimeStyle(styleId, cssRules.join('\n')); + updateNoticeStylesCore(this); } /** @@ -1802,18 +495,7 @@ export default class ThemeEngine extends Plugin { * @param content - The new CSS content to save. */ pushCssHistory(id: string, content: string) { - if (!this.cssHistory[id]) { - this.cssHistory[id] = { undoStack: [], redoStack: [] }; - } - const history = this.cssHistory[id]; - const lastState = history.undoStack[history.undoStack.length - 1]; - - // Only add to history if the content has actually changed - if (lastState !== content) { - history.undoStack.push(content); - // When a new action is taken, the redo stack must be cleared - history.redoStack = []; - } + pushCssHistoryCore(this, id, content); } /** @@ -1822,13 +504,7 @@ export default class ThemeEngine extends Plugin { * @returns The previous content, or null if no history. */ undoCssHistory(id: string): string | null { - const history = this.cssHistory[id]; - if (history && history.undoStack.length > 1) { - const currentState = history.undoStack.pop()!; - history.redoStack.push(currentState); - return history.undoStack[history.undoStack.length - 1]; - } - return null; // Can't undo the initial state + return undoCssHistoryCore(this, id); } /** @@ -1837,121 +513,22 @@ export default class ThemeEngine extends Plugin { * @returns The next content, or null if no redo history. */ redoCssHistory(id: string): string | null { - const history = this.cssHistory[id]; - if (history && history.redoStack.length > 0) { - const nextState = history.redoStack.pop()!; - history.undoStack.push(nextState); - return nextState; - } - return null; + return redoCssHistoryCore(this, id); } // Downloads an image from a URL and sets it as the background. async setBackgroundMediaFromUrl(url: string) { - if (!url) { - new Notice(t('notices.backgroundUrlLoadError')); - return; - } - try { - const response = await requestUrl({ url }); - const arrayBuffer = response.arrayBuffer; - - // Extract filename, remove query params - let fileName = url.split('/').pop(); - - // Clean up potential query parameters - if (fileName) { - fileName = fileName.split('?')[0]; // Get image data as ArrayBuffer - } - - // Generate fallback name if needed - if (!fileName || fileName.indexOf('.') === -1 || fileName.length > 50) { - const extension = response.headers['content-type']?.split('/')[1] || 'png'; - fileName = `image-${Date.now()}.${extension}`; - } - - // Pass to main function - await this.setBackgroundMedia(arrayBuffer, fileName, 'prompt'); - } catch (error) { - new Notice(t('notices.backgroundUrlLoadError')); - console.error('Theme Engine: Error fetching image from URL:', error); - } + await setBackgroundMediaFromUrlCore(this, url); } // Sets an existing media file as the active background. async selectBackgroundMedia(newPath: string, mediaType: 'image' | 'video') { - const activeProfile = this.settings.profiles?.[this.settings.activeProfile]; - if (!activeProfile) return; - - // Set the new path and type in the settings - activeProfile.backgroundPath = newPath; - activeProfile.backgroundType = mediaType; - activeProfile.backgroundEnabled = true; - - // Save settings (this will trigger applyBackgroundMedia to show it) - await this.saveSettings(); - - new Notice(t('notices.bgSet')); + await selectBackgroundMediaCore(this, newPath, mediaType); } // Renames a background file and updates ALL profiles using it. async renameBackgroundMedia(oldPath: string, newFullName: string): Promise { - const adapter = this.app.vault.adapter; - - // Validate name - 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; - } - - // Prevent overwriting *different* existing file - if (await adapter.exists(newPath)) { - if (oldPath.toLowerCase() !== newPath.toLowerCase()) { - new Notice(t('notices.filenameExists', newFullName)); - return false; - } - } - - // Attempt rename - try { - await adapter.rename(oldPath, newPath); - - // Update the path in ALL profiles that were using the old path - let settingsChanged = false; - for (const profileName in this.settings.profiles) { - const profile = this.settings.profiles[profileName]; - if (profile && profile.backgroundPath === oldPath) { - profile.backgroundPath = newPath; - settingsChanged = true; - } - } - - if (settingsChanged) { - await this.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; - } + return renameBackgroundMediaCore(this, oldPath, newFullName); } /** @@ -1959,48 +536,7 @@ export default class ThemeEngine extends Plugin { * Essential for determining base theme defaults. */ async captureCurrentComputedVars(): Promise> { - console.debug('Theme Engine: Capturing current computed styles...'); - - // 1. Flush plugin overrides and await repaint to expose base theme - this.clearStyles(); - await new Promise((resolve) => requestAnimationFrame(resolve)); - - const capturedVars: Record = {}; - const flatDefaultVars = flattenVars(DEFAULT_VARS); - const allVarKeys = Object.keys(flatDefaultVars); - - const bodyStyles = getComputedStyle(document.body); - - // Heuristic: Only treat a variable as a color if its internal default definition is color-like. - 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)) { - // Standardize computed values (often rgb()) to Hex for UI consistency - try { - capturedVars[varName] = convertColorToHex(computedValue); - } catch (e) { - console.warn(`Failed to convert color ${varName}: ${computedValue}`, e); - capturedVars[varName] = computedValue; - } - } else { - capturedVars[varName] = computedValue; - } - } - } - - console.debug(`Theme Engine: Captured ${Object.keys(capturedVars).length} variables.`); - - // 2. Restore plugin state - void this.applyStyles(); - await new Promise((resolve) => requestAnimationFrame(resolve)); - - return capturedVars; + return captureCurrentComputedVarsCore(this); } /** @@ -2008,19 +544,6 @@ export default class ThemeEngine extends Plugin { * Prevents UI flickering by caching defaults until theme mode (dark/light) changes. */ async getThemeDefaults(): Promise> { - const currentThemeMode = document.body.classList.contains('theme-dark') ? 'dark' : 'light'; - - if (this.cachedThemeDefaults && this.lastCachedThemeMode === currentThemeMode) { - return this.cachedThemeDefaults; - } - - console.debug('Theme Engine: Cache miss or theme change. Refreshing defaults...'); - - const newDefaults = await this.captureCurrentComputedVars(); - - this.cachedThemeDefaults = newDefaults; - this.lastCachedThemeMode = currentThemeMode; - - return newDefaults; + return getThemeDefaultsCore(this); } } diff --git a/src/ui/settings/render.ts b/src/ui/settings/render.ts new file mode 100644 index 0000000..ba65a42 --- /dev/null +++ b/src/ui/settings/render.ts @@ -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 => { + 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( + '.cm-var-row:not(.cm-hidden)', + ); + + if (firstVisibleRow) { + firstVisibleRow.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }, 0); + } + const scrollContainer = tab.containerEl.closest('.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'); +}; diff --git a/src/ui/settings/search.ts b/src/ui/settings/search.ts new file mode 100644 index 0000000..ed0e1a2 --- /dev/null +++ b/src/ui/settings/search.ts @@ -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('.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("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('.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('.cm-var-name'); + const descEl = row.querySelector('.setting-item-description'); + + highlightElement(nameEl); + highlightElement(descEl); +}; diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index 96230d4..55e515c 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -1,34 +1,17 @@ -import { - App, - ButtonComponent, - DropdownComponent, - Notice, - PluginSettingTab, - Setting, - SettingGroup, - setIcon, - SearchComponent, -} from 'obsidian'; +import { App, ButtonComponent, Notice, PluginSettingTab } from 'obsidian'; +import Sortable from 'sortablejs'; import { DEFAULT_VARS, TEXT_TO_BG_MAP } from '../constants'; import { t } from '../i18n/strings'; import type ThemeEngine from '../main'; -import { flattenVars, getAccessibilityRating, getContrastRatio, debounce } from '../utils'; -import { drawColorPickers } from './components/color-pickers'; -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 { flattenVars, getAccessibilityRating, getContrastRatio } from '../utils'; +import { renderSettingsTab } from './settings/render'; import { - AddNewLanguageModal, - LanguageTranslatorModal, - ConfirmationModal, - LanguageInfoModal, -} from './modals'; + applySearchFilter as applySearchFilterCore, + clearSearchAndFilters, + highlightRowMatches as highlightRowMatchesCore, + initSearchUI as initSearchUICore, + type SearchState, +} from './settings/search'; export class ThemeEngineSettingTab extends PluginSettingTab { plugin: ThemeEngine; @@ -37,12 +20,7 @@ export class ThemeEngineSettingTab extends PluginSettingTab { caseToggle: HTMLButtonElement; regexToggle: HTMLButtonElement; sectionSelect: HTMLSelectElement; - _searchState: { - query: string; - regex: boolean; - caseSensitive: boolean; - section: string; - }; + _searchState: SearchState; staticContentContainer: HTMLDivElement; resetPinBtn: ButtonComponent | null = null; pinBtn: ButtonComponent | null = null; @@ -92,340 +70,19 @@ export class ThemeEngineSettingTab extends PluginSettingTab { } _clearSearchAndFilters() { - if (!this.searchInput || !this.sectionSelect) return; - - 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(); + clearSearchAndFilters(this); } initSearchUI(containerEl: HTMLElement) { - // Load saved section here - 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(); - }); + initSearchUICore(this, containerEl); } _applySearchFilter() { - const s = this._searchState; - 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('.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("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('.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); - } + applySearchFilterCore(this); } - _highlightRowMatches(row: HTMLElement, state: typeof this._searchState) { - 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('.cm-var-name'); - const descEl = row.querySelector('.setting-item-description'); - - highlightElement(nameEl); - highlightElement(descEl); + _highlightRowMatches(row: HTMLElement, state: SearchState) { + highlightRowMatchesCore(row, state); } _updatePinButtons() { @@ -452,12 +109,12 @@ export class ThemeEngineSettingTab extends PluginSettingTab { } _getCurrentProfileJson() { - const p = this.plugin.settings.profiles?.[this.plugin.settings.activeProfile]; - if (!p) return null; + const profile = this.plugin.settings.profiles?.[this.plugin.settings.activeProfile]; + if (!profile) return null; return { name: this.plugin.settings.activeProfile, exportedAt: new Date().toISOString(), - profile: p, + profile, }; } @@ -481,12 +138,12 @@ export class ThemeEngineSettingTab extends PluginSettingTab { type: 'application/json', }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.download = `${this.plugin.settings.activeProfile}.profile.json`; - a.href = url; - a.click(); + const anchor = document.createElement('a'); + anchor.download = `${this.plugin.settings.activeProfile}.profile.json`; + anchor.href = url; + anchor.click(); URL.revokeObjectURL(url); - a.remove(); + anchor.remove(); new Notice(t('notices.exportSuccess')); } @@ -520,316 +177,12 @@ export class ThemeEngineSettingTab extends PluginSettingTab { } display(): void { - // call the async renderer and handle any error void this.renderDisplay().catch((err) => { console.error('Error in SettingsTab.renderDisplay:', err); }); } private async renderDisplay(): Promise { - const themeDefaults = await this.plugin.getThemeDefaults(); - - 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( - '.cm-var-row:not(.cm-hidden)', - ); - - if (firstVisibleRow) { - firstVisibleRow.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - }, 0); - } - const scrollContainer = this.containerEl.closest('.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'); + await renderSettingsTab(this); } }