From aa3640d332ddc424ca57926a3a2ae9cab36c2ea4 Mon Sep 17 00:00:00 2001 From: Yazan Ammar Date: Fri, 19 Sep 2025 18:30:03 +0300 Subject: [PATCH] Create main.ts --- src/main.ts | 826 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 826 insertions(+) create mode 100644 src/main.ts diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4455f46 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,826 @@ +/* + * Color Master - Obsidian Plugin + * Version: 1.0.7 + * Author: Yazan Ammar (GitHub : https://github.com/YazanAmmar ) + * Description: Provides a comprehensive UI to control all Obsidian CSS color variables directly, + * removing the need for Force Mode and expanding customization options. + */ +import { flattenVars } from "./utils"; +import { initializeT, t } from "./i18n"; +import { + Plugin, + moment, + PluginSettingTab, + Setting, + Notice, + Modal, + ButtonComponent, + setIcon, +} from "obsidian"; +import { PluginSettings } from "./types"; +import { + DEFAULT_VARS, + DEFAULT_SETTINGS, + TEXT_TO_BG_MAP, + BUILT_IN_PROFILES_VARS, + BUILT_IN_PROFILES_DATA, + COLOR_DESCRIPTIONS, + COLOR_DESCRIPTIONS_AR, + COLOR_NAMES, + COLOR_NAMES_AR, +} from "./constants"; +import { ColorMasterSettingTab } from "./ui/settingsTab"; + +let T: ColorMaster; + +export default class ColorMaster extends Plugin { + settings: PluginSettings; + iconizeWatcherInterval: number | null = null; + colorUpdateInterval: number | null = null; + pendingVarUpdates: Record = {}; + settingTabInstance: ColorMasterSettingTab | null = null; + liveNoticeRules: any[] | null = null; + liveNoticeRuleType: "text" | "background" | null = null; + iconizeObserver: MutationObserver; + noticeObserver: MutationObserver; + cssHistory: Record = {}; + + startColorUpdateLoop() { + this.stopColorUpdateLoop(); + + const fps = this.settings.colorUpdateFPS; + if (!this.settings.pluginEnabled || !fps || fps <= 0) { + return; // Stop if disabled or FPS is 0 + } + + const intervalMs = 1000 / fps; + this.colorUpdateInterval = window.setInterval(() => { + const pendingKeys = Object.keys(this.pendingVarUpdates); + if (pendingKeys.length === 0) return; // Do nothing if the box is empty + + for (const varName of pendingKeys) { + document.body.style.setProperty( + varName, + this.pendingVarUpdates[varName] + ); + } + + this.pendingVarUpdates = {}; // Empty the box after applying + + // Trigger updates for Graph View and accessibility checkers + this.app.workspace.trigger("css-change"); + + if (this.settingTabInstance) { + this.settingTabInstance?.updateAccessibilityCheckers(); + } + // ensure iconize icons update live using current computed var + try { + this.forceIconizeColors(); + } catch (e) { + console.warn("forceIconizeColors failed in update loop", e); + } + }, intervalMs); + } + + // New method to stop the update loop + stopColorUpdateLoop() { + if (this.colorUpdateInterval) { + window.clearInterval(this.colorUpdateInterval); + this.colorUpdateInterval = null; + } + } + + // New method to easily restart the loop when settings change + restartColorUpdateLoop() { + this.stopColorUpdateLoop(); + this.startColorUpdateLoop(); + } + + 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; + } + + const el = document.createElement("style"); + el.id = `cm-custom-css-for-profile`; + el.textContent = profile.customCss; + document.head.appendChild(el); + } catch (e) { + console.warn("applyCustomCssForProfile failed", e); + } + } + + removeInjectedCustomCss() { + try { + const oldStyle = document.getElementById("cm-custom-css-for-profile"); + if (oldStyle) oldStyle.remove(); + } catch (e) { + console.warn(e); + } + } + + applyCssSnippets() { + this.removeCssSnippets(); + const activeProfile = this.settings.profiles[this.settings.activeProfile]; + if (!activeProfile) return; + + const snippets = Array.isArray(activeProfile.snippets) + ? activeProfile.snippets + : []; + + const enabledCss = snippets + .filter((s) => s.enabled && s.css) + .map((s) => `/* Snippet: ${s.name} */\n${s.css}`) + .join("\n\n"); + + if (enabledCss) { + const el = document.createElement("style"); + el.id = "cm-css-snippets"; + el.textContent = enabledCss; + document.head.appendChild(el); + } + } + + removeCssSnippets() { + const el = document.getElementById("cm-css-snippets"); + if (el) el.remove(); + } + + // New method to 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) { + document.body.style.setProperty(k, pending[k]); + } + + // 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("Color Master: applyPendingNow failed", e); + } + } + + 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("NOTICE_PROFILE_NOT_FOUND")); + return; + } + + const snapshotData = { + vars: JSON.parse(JSON.stringify(profile.vars || {})), + customCss: profile.customCss || "", + snippets: JSON.parse(JSON.stringify(profile.snippets || {})), + noticeRules: JSON.parse( + JSON.stringify(profile.noticeRules || { text: [], background: [] }) + ), + }; + + this.settings.pinnedSnapshots[profileName] = { + pinnedAt: new Date().toISOString(), + ...snapshotData, + }; + return this.saveSettings(); + } + + async resetProfileToPinned(profileName: string): Promise { + if (!profileName) profileName = this.settings.activeProfile; + const snap = this.settings.pinnedSnapshots?.[profileName]; + if (!snap || !snap.vars) { + new Notice(t("NOTICE_NO_PINNED_SNAPSHOT")); + return; + } + + const activeProfile = this.settings.profiles[profileName]; + if (!activeProfile) { + new Notice(t("NOTICE_PROFILE_NOT_FOUND")); + return; + } + + activeProfile.vars = JSON.parse(JSON.stringify(snap.vars)); + activeProfile.customCss = snap.customCss || ""; + activeProfile.snippets = snap.snippets + ? JSON.parse(JSON.stringify(snap.snippets)) + : {}; + activeProfile.noticeRules = snap.noticeRules + ? JSON.parse(JSON.stringify(snap.noticeRules)) + : { text: [], background: [] }; + + Object.keys(snap.vars).forEach((k) => { + this.pendingVarUpdates[k] = snap.vars[k]; + }); + + await this.saveSettings(); + this.applyPendingNow(); + } + + resetIconizeWatcher() { + if (this.iconizeWatcherInterval) { + window.clearInterval(this.iconizeWatcherInterval); + } + + const intervalMilliseconds = this.settings.cleanupInterval * 1000; + + this.iconizeWatcherInterval = window.setInterval(() => { + const iconizeIDs = ["obsidian-icon-folder", "iconize"]; + const isIconizeInstalled = iconizeIDs.some( + (id) => (this.app as any).plugins.plugins[id] + ); + + if (!isIconizeInstalled) { + this.removeOrphanedIconizeElements(); + } + }, intervalMilliseconds); + + this.registerInterval(this.iconizeWatcherInterval); + } + + async onload() { + initializeT(this); + T = this; + await this.loadSettings(); + this.liveNoticeRules = null; + this.liveNoticeRuleType = null; + // --- New code: for automatic language detection --- + if (this.settings.language === "auto") { + const obsidianLang = moment.locale(); + if (obsidianLang === "ar") { + this.settings.language = "ar"; // If it is Arabic, we will designate it as Arabic. + } else { + this.settings.language = "en"; // For any other language, we set it to English. + } + await this.saveSettings(); + } + + T = this; + this.addCommand({ + id: "toggle-color-master", + name: "Toggle Color Master", + callback: async () => { + this.settings.pluginEnabled = !this.settings.pluginEnabled; + await this.saveSettings(); + new Notice( + this.settings.pluginEnabled + ? t("PLUGIN_ENABLED_NOTICE") + : t("PLUGIN_DISABLED_NOTICE") + ); + }, + }); + + this.addCommand({ + id: "cycle-color-profile", + name: "Cycle Color Master Profile", + callback: async () => { + const names = Object.keys(this.settings.profiles || {}); + if (names.length === 0) { + new Notice(t("NOTICE_NO_PROFILES_FOUND")); + return; + } + const idx = names.indexOf(this.settings.activeProfile); + const next = names[(idx + 1) % names.length]; + this.settings.activeProfile = next; + await this.saveSettings(); + new Notice(t("NOTICE_ACTIVE_PROFILE_SWITCHED", next)); + }, + }); + + // Store a reference to the settings tab and add it + this.settingTabInstance = new ColorMasterSettingTab(this.app, this); + this.addSettingTab(this.settingTabInstance); + + this.app.workspace.onLayoutReady(() => { + this.applyStyles(); + setTimeout(() => this.app.workspace.trigger("css-change"), 100); + + // Start the update engine + this.startColorUpdateLoop(); + + this.iconizeObserver = new MutationObserver(() => { + if ( + this.settings.pluginEnabled && + this.settings.overrideIconizeColors + ) { + this.forceIconizeColors(); + } + }); + + this.iconizeObserver.observe(document.body, { + childList: true, + subtree: true, + }); + + this.register(() => this.iconizeObserver.disconnect()); + }); + this.resetIconizeWatcher(); + this.noticeObserver = new MutationObserver((mutations) => { + if (!this.settings.pluginEnabled) return; + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== 1) return; + + if ((node as Element).matches(".notice, .toast")) { + this.processNotice(node as HTMLElement); + } + (node as Element) + .querySelectorAll(".notice, .toast") + .forEach(this.processNotice.bind(this)); + }); + }); + }); + + this.noticeObserver.observe(document.body, { + childList: true, + subtree: true, + }); + + document + .querySelectorAll(".notice, .toast") + .forEach(this.processNotice.bind(this)); + console.log("Color Master loaded with smart Iconize cleanup."); + } + + onunload() { + if (this.noticeObserver) this.noticeObserver.disconnect(); + this.clearStyles(); + this.removeInjectedCustomCss(); + this.stopColorUpdateLoop(); + console.log("Color Master v1.0.7 unloaded."); + } + + async refreshOpenGraphViews() { + const graphLeaves = this.app.workspace.getLeavesOfType("graph"); + if (graphLeaves.length === 0) { + return; + } + + console.log( + `Color Master: 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 }, + }); + } + } + + 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( + "Color Master: 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("*")].forEach((el: any) => { + if (typeof el.hasAttribute !== "function") return; + + if (!iconizeColor) { + // remove inline overrides to let theme/defaults show + el.style.fill = ""; + el.style.stroke = ""; + 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(") + ) { + try { + el.style.setProperty("fill", iconizeColor, "important"); + } catch (e) { + el.style.fill = iconizeColor; + } + } + + if (originalStroke && originalStroke !== "none") { + try { + el.style.setProperty("stroke", iconizeColor, "important"); + } catch (e) { + el.style.stroke = iconizeColor; + } + } + }); + }); + } + + removeOrphanedIconizeElements() { + const iconizeIDs = ["obsidian-icon-folder", "iconize"]; + const isIconizeInstalled = iconizeIDs.some( + (id) => (this.app as any).plugins.plugins[id] + ); + + // We only proceed if Iconize is actually installed. If so, we do nothing. + if (isIconizeInstalled) { + return; + } + // Find all elements with the .iconize-icon class and check if they have content. + const orphanedIcons = document.querySelectorAll(".iconize-icon"); + + // If we found any potential orphans, we log it for debugging. + if (orphanedIcons.length > 0) { + console.log( + `Color Master: Found ${orphanedIcons.length} orphaned Iconize elements. Cleaning up...` + ); + orphanedIcons.forEach((icon) => icon.remove()); + } + } + + applyStyles() { + this.removeOrphanedIconizeElements(); + this.clearStyles(); + if (!this.settings.pluginEnabled) { + this.removeInjectedCustomCss(); + return; + } + + const profile = this.settings.profiles[this.settings.activeProfile]; + if (!profile) { + console.error("Color Master: Active profile not found!"); + return; + } + + for (const [key, value] of Object.entries(profile.vars)) { + document.body.style.setProperty(key, value); + } + + this.forceIconizeColors(); + setTimeout(() => this.forceIconizeColors(), 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"); + } + this.applyCustomCssForProfile(this.settings.activeProfile); + this.applyCssSnippets(); + // Add/Remove body class for RTL notices + if (this.settings.language === "ar" || this.settings.language === "fa") { + document.body.classList.add("color-master-rtl"); + } else { + document.body.classList.remove("color-master-rtl"); + } + } + + clearStyles() { + 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.style.removeProperty(key); + }); + + document.querySelectorAll(".iconize-icon").forEach((iconNode) => { + const svg = iconNode.querySelector("svg"); + if (!svg) return; + + [svg, ...svg.querySelectorAll("*")].forEach((el: any) => { + if (typeof el.hasAttribute !== "function") return; + + el.style.removeProperty("fill"); + el.style.removeProperty("stroke"); + }); + }); + + const styleId = "color-master-overrides"; + const overrideStyleEl = document.getElementById(styleId); + if (overrideStyleEl) { + overrideStyleEl.remove(); + } + this.app.workspace.trigger("css-change"); + document.body.classList.remove("color-master-rtl"); + } + + 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.log( + "Color Master: 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 as any).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.log( + "Color Master: Notice rules migration complete. Saving new settings structure." + ); + await this.saveData(this.settings); + } + // --- Migration logic for existing users --- + let settingsChanged = false; + if (!this.settings.noticeRules) { + this.settings.noticeRules = { + text: [], + background: [], + }; + settingsChanged = true; + } + + if (settingsChanged) { + await this.saveData(this.settings); + } + // --- End of migration logic --- + if (!this.settings.installDate) { + this.settings.installDate = new Date().toISOString(); + await this.saveData(this.settings); + } + if (!this.settings.pinnedSnapshots) { + this.settings.pinnedSnapshots = {}; + } + + // Seed pinned snapshots for up to the first 5 profiles if they don't have one + try { + const profileNames = Object.keys(this.settings.profiles || {}); + let changed = false; + for (let i = 0; i < Math.min(5, profileNames.length); i++) { + const name = profileNames[i]; + if (!this.settings.pinnedSnapshots[name]) { + const vars = this.settings.profiles[name]?.vars || {}; + this.settings.pinnedSnapshots[name] = { + pinnedAt: new Date().toISOString(), + vars: JSON.parse(JSON.stringify(vars)), + }; + changed = true; + } + } + if (changed) { + console.log("Color Master: Seeding initial pinned snapshots."); + await this.saveData(this.settings); + } + 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 snippetsArray = Object.entries(profile.snippets).map( + ([name, data]: [string, any]) => ({ + id: `snippet-${Date.now()}-${Math.random()}`, + name: name, + css: data.css || "", + enabled: data.enabled !== false, + }) + ); + profile.snippets = snippetsArray; + } + } + + if (snippetsMigrationNeeded) { + console.log( + "Color Master: The clipping data structure is being migrated to the new format (array)." + ); + await this.saveData(this.settings); + } + } catch (e) { + console.warn("Color Master: failed to seed pinnedSnapshots", e); + } + } + + async saveSettings() { + await this.saveData(this.settings); + this.applyStyles(); + await this.refreshOpenGraphViews(); + this.app.workspace.trigger("css-change"); + } + + processNotice(el: HTMLElement) { + let noticeText = (el.textContent || "").toLowerCase(); + const testKeywordEl = el.querySelector(".cm-test-keyword") as HTMLElement; + + if (testKeywordEl) { + noticeText = (testKeywordEl.textContent || "").toLowerCase(); + } + if (!el || !el.classList || el.dataset.cmProcessed === "true") return; + + 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; + + const bgRules = + liveRuleType === "background" && liveRules + ? liveRules + : activeProfile?.noticeRules?.background || []; + + const textRules = + liveRuleType === "text" && liveRules + ? liveRules + : activeProfile?.noticeRules?.text || []; + + let finalBgColor = activeProfile.vars["--cm-notice-bg-default"]; + let finalTextColor = activeProfile.vars["--cm-notice-text-default"]; + + for (const rule of bgRules) { + if (!rule.keywords || !rule.keywords.trim()) continue; + const keywords = (rule.keywords || "").toLowerCase(); + let match = false; + + if (rule.isRegex) { + try { + const regex = new RegExp(keywords, "i"); + if (regex.test(noticeText)) match = true; + } catch (e) {} + } else { + const keywordArray = keywords + .split(",") + .map((k: string) => k.trim()) + .filter(Boolean); + for (const keyword of keywordArray) { + if (noticeText.includes(keyword)) { + match = true; + break; + } + } + } + if (match) { + finalBgColor = rule.color; + break; + } + } + + for (const rule of textRules) { + if (!rule.keywords || !rule.keywords.trim()) continue; + const keywords = (rule.keywords || "").toLowerCase(); + let match = false; + + if (rule.isRegex) { + try { + const regex = new RegExp(keywords, "i"); + if (regex.test(noticeText)) match = true; + } catch (e) {} + } else { + const keywordArray = keywords + .split(",") + .map((k: string) => k.trim()) + .filter(Boolean); + for (const keyword of keywordArray) { + if (noticeText.includes(keyword)) { + match = true; + break; + } + } + } + + if (match) { + finalTextColor = rule.color; + break; + } + } + el.style.setProperty("background-color", finalBgColor, "important"); + el.style.setProperty("color", finalTextColor, "important"); + } catch (e) { + console.warn("Color Master: processNotice failed", e); + } + } + + /** + * Pushes a new state to the history for a given CSS snippet/profile. + * @param id - A unique identifier for the snippet or profile. + * @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 = []; + } + } + + /** + * Undoes the last change for a given ID and returns the previous state. + * @param id - The unique identifier. + * @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 + } + + /** + * Redoes the last undone change for a given ID. + * @param id - The unique identifier. + * @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; + } +}