├── .npmrc ├── .eslintignore ├── versions.json ├── .editorconfig ├── .gitignore ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE ├── esbuild.config.mjs ├── styles.css ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "virtual-footer", 3 | "name": "Virtual Content", 4 | "version": "1.0.24", 5 | "minAppVersion": "0.15.0", 6 | "description": "Display markdown text (including dataview queries or Obsidian bases) at the bottom, top or in the sidebar for all notes which match a specified rule, without modifying them.", 7 | "author": "Signynt", 8 | "authorUrl": "https://github.com/Signynt", 9 | "fundingUrl": "", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "virtual-footer", 3 | "version": "1.0.0", 4 | "description": "Display markdown text (including dataview queries) at the bottom of all notes in a folder, without modifying them.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "^0.25.8", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vincenzo Mitchell Barroso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .markdown-source-view.mod-cm6.is-readable-line-width .cm-sizer>.virtual-footer-dynamic-content-element { 2 | max-width: var(--max-width); 3 | width: var(--line-width); 4 | margin-inline: var(--content-margin); 5 | } 6 | 7 | .virtual-footer-cm-padding { 8 | padding-bottom: var(--p-spacing) !important; 9 | } 10 | 11 | .virtual-footer-remove-flex { 12 | flex: 0 1 auto !important; 13 | } 14 | 15 | .virtual-footer-footer-group{ 16 | min-height: 528px; 17 | } 18 | 19 | .virtual-footer-footer-group.virtual-footer-above-backlinks { 20 | min-height: 100px; 21 | } 22 | 23 | .virtual-footer-delete-button { 24 | margin: 1em; 25 | } 26 | 27 | .virtual-footer-add-button { 28 | margin: 1em; 29 | } 30 | 31 | .virtual-footer-rule-item h4 { 32 | cursor: pointer; 33 | user-select: none; 34 | position: relative; 35 | /* Space for the icon */ 36 | padding-left: 22px; 37 | margin-bottom: 0.5em; 38 | } 39 | .virtual-footer-rule-item h4::before { 40 | /* Collapsed state: right-pointing triangle */ 41 | content: '▶'; 42 | position: absolute; 43 | left: 0; 44 | top: 50%; 45 | transform: translateY(-50%) scale(0.9); 46 | /* Adjust size as needed */ 47 | font-size: 1em; 48 | } 49 | .virtual-footer-rule-item:not(.is-collapsed) h4::before { 50 | content: '▼'; 51 | } 52 | .virtual-footer-rule-item.is-collapsed .virtual-footer-rule-content { 53 | display: none; 54 | } 55 | .virtual-footer-rule-content { 56 | /* Indent content to align with text after icon */ 57 | padding-left: 22px; 58 | /* Optional visual cue */ 59 | /* border-left: 1px solid var(--background-modifier-border); */ 60 | /* Align border with icon center */ 61 | /* margin-left: 2px; */ 62 | padding-bottom: 10px; 63 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Virtual Content 2 | > Previously known as "Virtual Footer" 3 | 4 | Set rules to add markdown text to the bottom or top of files based on rules. This text get's rendered normally, including dataview blocks or Obsidian Bases. Your notes don't get modified or changed, the given markdown text is simply rendered "virtually". Rules can be applied to folders, tags or properties. The content to be included can be entered directly in the plugin settings, or come from a file in your vault. 5 | 6 | This is especially useful if you have many files with the same dataview block. Instead of pasting the dataview codeblock into every note, you can simply add it with this plugin. This prevents unecessary file bloat, while also letting you easily change the code for all files at the same time. 7 | 8 | ## Features 9 | - Works with Dataview, Datacore and native Obisidan Bases 10 | - Lets you define rules using folderes, tags and properties 11 | - Rules can be set to include or exclude subfolders and subtags (recursive matching) 12 | - Multi-condition rules are possible, allowing you to define multiple conditions for one rule (using AND/OR) 13 | - Dataview rules can be used to create complex conditions 14 | - Lets you select wether the "virtual content" gets added as a footer (end of file), a header (below properties) or in the sidebar 15 | - Lets you choose if all sidebar "virtual content" gets added to the same sidebar tab, or if it should be shown in it's own tab 16 | - Allows for "virtual content" to be defined in the plugin settings, or in a markdown file 17 | - Rules can be enabled or disabled from the plugin settings 18 | 19 | ## Example use cases 20 | 21 | ### Universally defined dataview for showing authors works 22 | I have a folder called "Authors" which contains a note on each author of media I've read/watched. I want to see what media the Author has made when I open the note, so I use the following dataview query to query that info from my media notes: 23 | 24 | ``````md 25 | #### Made 26 | ```dataview 27 | TABLE without ID 28 | file.link AS "Name" 29 | FROM "References/Media Thoughts" 30 | WHERE contains(creator, this.file.link) 31 | SORT file.link DESC 32 | ``` 33 | `````` 34 | 35 | Instead of having to add this to each file, I can simply add a rule to the folder "Authors" which contains the above text, and it will be automatically shown in each file. 36 | I can do this with as many folders as I like. 37 | 38 | ![virtual-footer-screenshot](https://github.com/user-attachments/assets/1251ece2-ad92-4393-9284-6c51d3567b6b) 39 | 40 | ![image](https://github.com/user-attachments/assets/1caa8991-eec1-42a2-96da-ad5526acbc36) 41 | 42 | ### Customizable backlinks 43 | Some users use Virtual Content to sort their backlinks based on folder or tag. 44 | 45 | ### Displaying tags used in a file 46 | Other users use Virtual Content at the top of a file to show tags used in the body of their notes. Check out [this issue](https://github.com/Signynt/virtual-content/issues/5#issuecomment-2919648582) for examples! 47 | 48 | ### Displaying related notes in your daily note 49 | I use this dataviewjs to display notes which were created, modified on that day or reference my daily note. 50 | 51 | ![image](https://github.com/user-attachments/assets/cbd45a04-7ace-498b-bdd4-c025b8b71315) 52 | 53 | `````md 54 | ```dataviewjs 55 | const currentDate = dv.current().file.name; // Get the current journal note's date (YYYY-MM-DD) 56 | 57 | // Helper function to extract the date part (YYYY-MM-DD) from a datetime string as a plain string 58 | const extractDate = (datetime) => { 59 | if (!datetime) return "No date"; 60 | if (typeof datetime === "string") { 61 | return datetime.split("T")[0]; // Split at "T" to extract the date 62 | } 63 | return "Invalid format"; // Fallback if not a string 64 | }; 65 | 66 | const thoughts = dv.pages('"Thoughts"') 67 | .where(p => { 68 | const createdDate = p.created ? extractDate(String(p.created)) : null; 69 | const modifiedDate = p.modified ? extractDate(String(p.modified)) : null; 70 | return createdDate === currentDate || modifiedDate === currentDate; 71 | }); 72 | 73 | const wiki = dv.pages('"Wiki"') 74 | .where(p => { 75 | const createdDate = p.created ? extractDate(String(p.created)) : null; 76 | const modifiedDate = p.modified ? extractDate(String(p.modified)) : null; 77 | return createdDate === currentDate || modifiedDate === currentDate; 78 | }); 79 | 80 | const literatureNotes = dv.pages('"References/Literature"') 81 | .where(p => { 82 | const createdDate = p.created ? extractDate(String(p.created)) : null; 83 | const modifiedDate = p.modified ? extractDate(String(p.modified)) : null; 84 | return createdDate === currentDate || modifiedDate === currentDate; 85 | }); 86 | 87 | const mediaThoughts = dv.pages('"References/Media"') 88 | .where(p => { 89 | // Check only for files that explicitly link to the daily note 90 | const linksToCurrent = p.file.outlinks && p.file.outlinks.some(link => link.path === dv.current().file.path); 91 | return linksToCurrent; 92 | }); 93 | 94 | const mediaWatched = dv.pages('"References/Media"') 95 | .where(p => { 96 | const startedDate = p.started ? extractDate(String(p.started)) : null; 97 | const finishedDate = p.finished ? extractDate(String(p.finished)) : null; 98 | return startedDate === currentDate || finishedDate === currentDate; 99 | }); 100 | 101 | const relatedFiles = [...thoughts, ...mediaThoughts, ...mediaWatched, ...wiki, ...literatureNotes]; 102 | 103 | if (relatedFiles.length > 0) { 104 | dv.el("div", 105 | `> [!related]+\n` + 106 | relatedFiles.map(p => `> - ${p.file.link}`).join("\n") 107 | ); 108 | } else { 109 | dv.el("div", `> [!related]+\n> - No related files found.`); 110 | } 111 | ``` 112 | ````` 113 | 114 | ### Displaying dataview in the sidebar 115 | You can also use Virtual Content to display dataview (or anything else) in the sidebar. This is useful if you want to see the results of a dataview query without having to scroll to the bottom of the file. 116 | Just select the "Sidebar" option in the settings, and use the "Open virtual content in sidebar" command. 117 | 118 | ![Untitled](https://github.com/user-attachments/assets/0fa7067a-596e-422b-b676-3f435fa1d49b) 119 | 120 | ### Applying complex rules using Dataview 121 | You can use Dataview queries to create complex rules. For example, you can create a rule that applies to all notes in a specific folder, but only if they begin with a certain prefix. 122 | It is recommended to use the Dataview option for very complex rules, as it allows for more flexibility and power than the built-in multi-condition rules. 123 | 124 | Example dataview rules: 125 | ``` 126 | LIST FROM "References/Authors" WHERE startswith(file.name, "Test") OR startswith(file.name, "Example") 127 | ``` 128 | 129 | ``` 130 | LIST FROM "Tasks/Reports" WHERE (Tags = work AND status = "done") OR progress > 50 131 | ``` 132 | 133 | ### Showing virtual content in an expandable pop up 134 | Check out [this issue](https://github.com/Signynt/virtual-content/issues/33) to see how a user turned the virtual content into a pop up which displays when you hover over it! 135 | 136 | https://github.com/user-attachments/assets/2125c038-9298-4c8b-9072-d40888882635 137 | 138 | ```css 139 | .daily-note .virtual-footer-dynamic-content-element.virtual-footer-header-group.virtual-footer-header-rendered-content{ 140 | position: absolute; 141 | opacity: 0.3; 142 | width: 800px !important; 143 | border-radius: 12px; 144 | top: 300px; 145 | left: -750px; 146 | z-index: 1; 147 | transition: all 0.4s ease; /* 所有属性添加0.2秒渐变效果 */ 148 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 149 | outline: 1px solid var(--background-modifier-border); 150 | 151 | &:hover { 152 | z-index: 100; 153 | scale: 1.0; 154 | opacity: 1.0; 155 | background-color: var(--background-secondary); 156 | transform: translate(756px, 0px); 157 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 158 | backdrop-filter: blur(10px); 159 | } 160 | } 161 | ``` 162 | 163 | In the above snippet it's limited to `.daily-note` so that this style only applies to notes with `cssclasses: daily-note`. 164 | 165 | ## Limitations 166 | 167 | Links in the markdown text work natively when in Reading mode, however they don't in Live Preview, so I've added a workaround that gets most functionality back. This means that `left click` works to open the link in the current tab, and `middle mouse` and `ctrl/cmd + left click` works to open the link in a new tab. Right click currently doesn't work. 168 | 169 | ## Support 170 | You can send me a donation using [my Paypal link](https://paypal.me/VincenzoBarroso). Thanks for the support! 171 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Plugin, 4 | PluginSettingTab, 5 | Setting, 6 | MarkdownView, 7 | MarkdownRenderer, 8 | AbstractInputSuggest, 9 | Component, 10 | TFile, 11 | getAllTags, 12 | ItemView, 13 | WorkspaceLeaf, 14 | debounce, 15 | } from 'obsidian'; 16 | 17 | // --- Enums --- 18 | 19 | /** Defines the type of a rule, determining how it matches files (e.g., by folder, tag, or property). */ 20 | enum RuleType { 21 | Folder = 'folder', 22 | Tag = 'tag', 23 | Property = 'property', 24 | Multi = 'multi', 25 | Dataview = 'dataview', 26 | } 27 | 28 | /** Defines the source of the content for a rule (e.g., direct text input or a markdown file). */ 29 | enum ContentSource { 30 | Text = 'text', 31 | File = 'file', 32 | } 33 | 34 | /** Defines where the dynamic content should be rendered within the Markdown view (e.g., header or footer). */ 35 | enum RenderLocation { 36 | Footer = 'footer', 37 | Header = 'header', 38 | Sidebar = 'sidebar', 39 | } 40 | 41 | // --- Interfaces --- 42 | 43 | /** 44 | * Represents a single condition for a 'Multi' rule type. 45 | */ 46 | interface SubCondition { 47 | /** The type of condition (folder, tag, or property). */ 48 | type: 'folder' | 'tag' | 'property'; 49 | /** Whether this condition should be negated (not met). Defaults to false. */ 50 | negated?: boolean; 51 | /** For 'folder' type: path to the folder. */ 52 | path?: string; 53 | /** For 'folder' type: whether to match subfolders. */ 54 | recursive?: boolean; 55 | /** For 'tag' type: the tag name (without '#'). */ 56 | tag?: string; 57 | /** For 'tag' type: whether to match subtags. */ 58 | includeSubtags?: boolean; 59 | /** For 'property' type: the name of the frontmatter property. */ 60 | propertyName?: string; 61 | /** For 'property' type: the value the frontmatter property should have. */ 62 | propertyValue?: string; 63 | } 64 | 65 | /** 66 | * Represents a rule for injecting dynamic content into Markdown views. 67 | * Each rule specifies matching criteria (type: folder/tag/property), content source (text/file), 68 | * the content itself, and where it should be rendered (header/footer). 69 | */ 70 | interface Rule { 71 | /** A descriptive name for this rule. */ 72 | name?: string; 73 | /** Whether this rule is currently active. */ 74 | enabled?: boolean; 75 | /** The type of criteria for this rule (folder-based, tag-based, or property-based). */ 76 | type: RuleType; 77 | /** Whether this rule's condition should be negated (not met). Defaults to false. */ 78 | negated?: boolean; 79 | /** For 'folder' type: path to the folder. "" for all files, "/" for root. */ 80 | path?: string; 81 | /** For 'tag' type: the tag name (without '#'). */ 82 | tag?: string; 83 | /** For 'folder' type: whether to match subfolders. Defaults to true. Ignored if path is "". */ 84 | recursive?: boolean; 85 | /** For 'tag' type: whether to match subtags (e.g., 'tag' matches 'tag/subtag'). Defaults to false. */ 86 | includeSubtags?: boolean; 87 | /** For 'property' type: the name of the frontmatter property. */ 88 | propertyName?: string; 89 | /** For 'property' type: the value the frontmatter property should have. */ 90 | propertyValue?: string; 91 | /** For 'multi' type: an array of sub-conditions. */ 92 | conditions?: SubCondition[]; 93 | /** For 'multi' type: specifies whether ANY or ALL conditions must be met. Defaults to 'any'. */ 94 | multiConditionLogic?: 'any' | 'all'; 95 | /** For 'dataview' type: the Dataview query to use for matching files. */ 96 | dataviewQuery?: string; 97 | /** The source from which to get the content (direct text or a file). */ 98 | contentSource: ContentSource; 99 | /** Direct text content if contentSource is 'text'. */ 100 | footerText: string; // Retained name for compatibility, though it can be header or footer content. 101 | /** Path to a .md file if contentSource is 'file'. */ 102 | footerFilePath?: string; // Retained name for compatibility. 103 | /** Specifies whether to render in the header or footer. */ 104 | renderLocation: RenderLocation; 105 | /** For 'sidebar' location: whether to show in a separate tab. */ 106 | showInSeparateTab?: boolean; 107 | /** For 'sidebar' location: the name of the separate tab. */ 108 | sidebarTabName?: string; 109 | /** For 'header' location: whether to render above the properties section. */ 110 | renderAboveProperties?: boolean; 111 | /** For 'footer' location: whether to render above the backlinks section. */ 112 | renderAboveBacklinks?: boolean; 113 | /** Whether to show this rule's content in popover views. */ 114 | showInPopover?: boolean; 115 | } 116 | 117 | /** 118 | * Defines the settings structure for the VirtualFooter plugin. 119 | * Contains an array of rules that dictate content injection. 120 | */ 121 | interface VirtualFooterSettings { 122 | rules: Rule[]; 123 | /** Whether to refresh the view on file open. Defaults to false. */ 124 | refreshOnFileOpen?: boolean; 125 | /** Whether to render content in source mode. Defaults to false. */ 126 | renderInSourceMode?: boolean; 127 | /** Whether to refresh the view when note metadata changes. Defaults to false. */ 128 | refreshOnMetadataChange?: boolean; 129 | } 130 | 131 | /** 132 | * Extends HTMLElement to associate an Obsidian Component for lifecycle management. 133 | * This allows Obsidian to manage resources tied to the DOM element. 134 | */ 135 | interface HTMLElementWithComponent extends HTMLElement { 136 | /** The Obsidian Component associated with this HTML element. */ 137 | component?: Component; 138 | } 139 | 140 | // --- Constants --- 141 | 142 | /** Default settings for the plugin, used when no settings are found or for new rules. */ 143 | const DEFAULT_SETTINGS: VirtualFooterSettings = { 144 | rules: [{ 145 | name: 'Default Rule', 146 | enabled: true, 147 | type: RuleType.Folder, 148 | path: '', // Matches all files by default 149 | recursive: true, 150 | contentSource: ContentSource.Text, 151 | footerText: '', // Default content is empty 152 | renderLocation: RenderLocation.Footer, 153 | showInSeparateTab: false, 154 | sidebarTabName: '', 155 | multiConditionLogic: 'any', 156 | renderAboveProperties: false, 157 | renderAboveBacklinks: false, 158 | showInPopover: true, 159 | }], 160 | refreshOnFileOpen: false, // Default to false 161 | renderInSourceMode: false, // Default to false 162 | refreshOnMetadataChange: false, // Default to false 163 | }; 164 | 165 | // CSS Classes for styling and identifying plugin-generated elements 166 | const CSS_DYNAMIC_CONTENT_ELEMENT = 'virtual-footer-dynamic-content-element'; 167 | const CSS_HEADER_GROUP_ELEMENT = 'virtual-footer-header-group'; 168 | const CSS_FOOTER_GROUP_ELEMENT = 'virtual-footer-footer-group'; 169 | const CSS_HEADER_RENDERED_CONTENT = 'virtual-footer-header-rendered-content'; 170 | const CSS_FOOTER_RENDERED_CONTENT = 'virtual-footer-footer-rendered-content'; 171 | const CSS_VIRTUAL_FOOTER_CM_PADDING = 'virtual-footer-cm-padding'; // For CodeMirror live preview footer spacing 172 | const CSS_VIRTUAL_FOOTER_REMOVE_FLEX = 'virtual-footer-remove-flex'; // For CodeMirror live preview footer layout 173 | const CSS_ABOVE_BACKLINKS = 'virtual-footer-above-backlinks'; // For removing min-height when above backlinks 174 | 175 | // DOM Selectors for targeting elements in Obsidian's interface 176 | const SELECTOR_EDITOR_CONTENT_AREA = '.cm-editor .cm-content'; 177 | const SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT = '.markdown-source-view.mod-cm6 .cm-contentContainer'; 178 | const SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER = '.cm-contentContainer'; 179 | const SELECTOR_EDITOR_SIZER = '.cm-sizer'; // Target for live preview footer injection 180 | const SELECTOR_PREVIEW_HEADER_AREA = '.mod-header.mod-ui'; // Target for reading mode header injection 181 | const SELECTOR_PREVIEW_FOOTER_AREA = '.mod-footer'; // Target for reading mode footer injection 182 | const SELECTOR_EMBEDDED_BACKLINKS = '.embedded-backlinks'; // Target for positioning above backlinks 183 | const SELECTOR_METADATA_CONTAINER = '.metadata-container'; // Target for positioning above properties 184 | 185 | const VIRTUAL_CONTENT_VIEW_TYPE = 'virtual-content-view'; 186 | const VIRTUAL_CONTENT_SEPARATE_VIEW_TYPE_PREFIX = 'virtual-content-separate-view-'; 187 | 188 | // --- Utility Classes --- 189 | 190 | /** 191 | * A suggestion provider for input fields, offering autocompletion from a given set of strings. 192 | */ 193 | export class MultiSuggest extends AbstractInputSuggest { 194 | /** 195 | * Creates an instance of MultiSuggest. 196 | * @param inputEl The HTML input element to attach the suggester to. 197 | * @param content The set of strings to use as suggestions. 198 | * @param onSelectCb Callback function executed when a suggestion is selected. 199 | * @param app The Obsidian App instance. 200 | */ 201 | constructor( 202 | private inputEl: HTMLInputElement, 203 | private content: Set, 204 | private onSelectCb: (value: string) => void, 205 | app: App 206 | ) { 207 | super(app, inputEl); 208 | } 209 | 210 | /** 211 | * Filters the content set to find suggestions matching the input string. 212 | * @param inputStr The current string in the input field. 213 | * @returns An array of matching suggestion strings. 214 | */ 215 | getSuggestions(inputStr: string): string[] { 216 | const lowerCaseInputStr = inputStr.toLocaleLowerCase(); 217 | return [...this.content].filter((contentItem) => 218 | contentItem.toLocaleLowerCase().includes(lowerCaseInputStr) 219 | ); 220 | } 221 | 222 | /** 223 | * Renders a single suggestion item in the suggestion list. 224 | * @param content The suggestion string to render. 225 | * @param el The HTMLElement to render the suggestion into. 226 | */ 227 | renderSuggestion(content: string, el: HTMLElement): void { 228 | el.setText(content); 229 | } 230 | 231 | /** 232 | * Handles the selection of a suggestion. 233 | * @param content The selected suggestion string. 234 | * @param _evt The mouse or keyboard event that triggered the selection. 235 | */ 236 | selectSuggestion(content: string, _evt: MouseEvent | KeyboardEvent): void { 237 | this.onSelectCb(content); 238 | this.inputEl.value = content; // Update input field with selected value 239 | this.inputEl.blur(); // Remove focus from input 240 | this.close(); // Close the suggestion popover 241 | } 242 | } 243 | 244 | // --- Sidebar View Class --- 245 | 246 | export class VirtualContentView extends ItemView { 247 | plugin: VirtualFooterPlugin; 248 | viewContent: HTMLElement; 249 | component: Component; 250 | private contentProvider: () => { content: string, sourcePath: string } | null; 251 | private viewId: string; 252 | private tabName: string; 253 | 254 | constructor(leaf: WorkspaceLeaf, plugin: VirtualFooterPlugin, viewId: string, tabName: string, contentProvider: () => { content: string, sourcePath: string } | null) { 255 | super(leaf); 256 | this.plugin = plugin; 257 | this.viewId = viewId; 258 | this.tabName = tabName; 259 | this.contentProvider = contentProvider; 260 | } 261 | 262 | getViewType() { 263 | return this.viewId; 264 | } 265 | 266 | getDisplayText() { 267 | return this.tabName; 268 | } 269 | 270 | getIcon() { 271 | return 'text-select'; 272 | } 273 | 274 | protected async onOpen(): Promise { 275 | this.component = new Component(); 276 | this.component.load(); 277 | 278 | const container = this.containerEl.children[1]; 279 | container.empty(); 280 | this.viewContent = container.createDiv({ cls: 'virtual-content-sidebar-view' }); 281 | this.update(); 282 | } 283 | 284 | 285 | 286 | protected async onClose(): Promise { 287 | this.component.unload(); 288 | } 289 | 290 | update() { 291 | if (!this.viewContent) return; 292 | 293 | // Clean up previous content and component 294 | this.viewContent.empty(); 295 | this.component.unload(); 296 | this.component = new Component(); 297 | this.component.load(); 298 | 299 | const data = this.contentProvider(); 300 | if (data && data.content && data.content.trim() !== '') { 301 | MarkdownRenderer.render(this.app, data.content, this.viewContent, data.sourcePath, this.component); 302 | this.plugin.attachInternalLinkHandlers(this.viewContent, data.sourcePath, this.component); 303 | } else { 304 | this.viewContent.createEl('p', { 305 | text: 'No virtual content to display for the current note.', 306 | cls: 'virtual-content-sidebar-empty' 307 | }); 308 | } 309 | } 310 | } 311 | 312 | // --- Main Plugin Class --- 313 | 314 | /** 315 | * VirtualFooterPlugin dynamically injects content into the header or footer of Markdown views 316 | * based on configurable rules. 317 | */ 318 | export default class VirtualFooterPlugin extends Plugin { 319 | settings: VirtualFooterSettings; 320 | /** Stores pending content injections for preview mode, awaiting DOM availability. */ 321 | private pendingPreviewInjections: WeakMap = new WeakMap(); 328 | /** Manages MutationObservers for views in preview mode to detect when injection targets are ready. */ 329 | private previewObservers: WeakMap = new WeakMap(); 330 | private initialLayoutReadyProcessed = false; 331 | private lastSidebarContent: { content: string, sourcePath: string } | null = null; 332 | private lastSeparateTabContents: Map = new Map(); 333 | private lastHoveredLink: HTMLElement | null = null; 334 | 335 | /** 336 | * Called when the plugin is loaded. 337 | */ 338 | async onload() { 339 | await this.loadSettings(); 340 | this.addSettingTab(new VirtualFooterSettingTab(this.app, this)); 341 | 342 | this.registerView( 343 | VIRTUAL_CONTENT_VIEW_TYPE, 344 | (leaf) => new VirtualContentView(leaf, this, VIRTUAL_CONTENT_VIEW_TYPE, 'Virtual Content', () => this.getLastSidebarContent()) 345 | ); 346 | 347 | this.registerDynamicViews(); 348 | 349 | this.addRibbonIcon('text-select', 'Open virtual content in sidebar', () => { 350 | this.activateView(VIRTUAL_CONTENT_VIEW_TYPE); 351 | }); 352 | 353 | this.addCommand({ 354 | id: 'open-virtual-content-sidebar', 355 | name: 'Open virtual content in sidebar', 356 | callback: () => { 357 | this.activateView(VIRTUAL_CONTENT_VIEW_TYPE); 358 | }, 359 | }); 360 | 361 | this.addCommand({ 362 | id: 'open-all-virtual-content-sidebar-tabs', 363 | name: 'Open all virtual footer sidebar tabs', 364 | callback: () => { 365 | this.activateAllSidebarViews(); 366 | }, 367 | }); 368 | 369 | // Define event handlers 370 | const handleViewUpdate = () => { 371 | // Always trigger an update if the layout is ready. 372 | // Used for file-open and layout-change. 373 | if (this.initialLayoutReadyProcessed) { 374 | this.handleActiveViewChange(); 375 | } 376 | }; 377 | 378 | const handleFocusChange = () => { 379 | // This is the "focus change" or "switching files" part, conditional on the setting. 380 | // Used for active-leaf-change. 381 | if (this.settings.refreshOnFileOpen && this.initialLayoutReadyProcessed) { 382 | this.handleActiveViewChange(); 383 | } 384 | }; 385 | 386 | // Register event listeners 387 | this.registerEvent( 388 | this.app.workspace.on('file-open', handleViewUpdate) 389 | ); 390 | this.registerEvent( 391 | this.app.workspace.on('layout-change', handleViewUpdate) 392 | ); 393 | this.registerEvent( 394 | this.app.workspace.on('active-leaf-change', handleFocusChange) 395 | ); 396 | 397 | // Listen for metadata changes on the current file 398 | this.registerEvent( 399 | this.app.metadataCache.on('changed', (file) => { 400 | // Only refresh if the metadata change setting is enabled 401 | if (this.settings.refreshOnMetadataChange && this.initialLayoutReadyProcessed) { 402 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 403 | // Only refresh if the changed file is the currently active one 404 | if (activeView && activeView.file && file.path === activeView.file.path) { 405 | this.handleActiveViewChange(); 406 | } 407 | } 408 | }) 409 | ); 410 | 411 | // Listen for hover events to detect when popovers are created 412 | this.registerDomEvent(document, 'mouseover', (event: MouseEvent) => { 413 | const target = event.target as HTMLElement; 414 | // Check if the target is a link that could trigger a popover 415 | if (target.matches('a.internal-link, .internal-link a, [data-href]')) { 416 | // Store the last hovered link for popover file path extraction 417 | this.lastHoveredLink = target; 418 | // Delay to allow popover to be created 419 | setTimeout(() => {this.processPopoverViews();}, 100); 420 | } 421 | }); 422 | 423 | // Listen for clicks to detect when popovers might switch to editing mode 424 | this.registerDomEvent(document, 'click', (event: MouseEvent) => { 425 | const target = event.target as HTMLElement; 426 | // Check if the click is within a popover 427 | const popover = target.closest('.popover.hover-popover'); 428 | if (popover) { 429 | //console.log("VirtualContent: Click detected in popover, checking for mode change"); 430 | // Delay to allow any mode changes to complete 431 | setTimeout(() => {this.processPopoverViews();}, 150); 432 | } 433 | }); 434 | 435 | // Also listen for DOM mutations to catch dynamically created popovers 436 | const popoverObserver = new MutationObserver((mutations) => { 437 | for (const mutation of mutations) { 438 | if (mutation.type === 'childList') { 439 | mutation.addedNodes.forEach(node => { 440 | if (node instanceof HTMLElement) { 441 | // Check if a popover was added 442 | if (node.classList.contains('popover') && node.classList.contains('hover-popover')) { 443 | //console.log("VirtualContent: Popover created, processing views"); 444 | // Small delay to ensure the popover content is fully loaded 445 | setTimeout(() => {this.processPopoverViews();}, 50); 446 | } 447 | // Also check for popovers added within other elements 448 | const popovers = node.querySelectorAll('.popover.hover-popover'); 449 | if (popovers.length > 0) { 450 | //console.log("VirtualContent: Popover(s) found in added content, processing views"); 451 | setTimeout(() => {this.processPopoverViews();}, 50); 452 | } 453 | } 454 | }); 455 | } 456 | // Listen for attribute changes that might indicate mode switching in popovers 457 | if (mutation.type === 'attributes' && mutation.target instanceof HTMLElement) { 458 | const target = mutation.target; 459 | // Check if this is a popover that gained or lost the is-editing class 460 | if (target.classList.contains('popover') && target.classList.contains('hover-popover')) { 461 | if (mutation.attributeName === 'class') { 462 | const hasEditingClass = target.classList.contains('is-editing'); 463 | //console.log(`VirtualContent: Popover mode changed, is-editing: ${hasEditingClass}`); 464 | //setTimeout(() => {this.processPopoverViews();}, 100); // Slightly longer delay for mode changes 465 | } 466 | } 467 | } 468 | } 469 | }); 470 | 471 | // Observe the entire document for popover creation 472 | popoverObserver.observe(document.body, { 473 | childList: true, 474 | subtree: true 475 | }); 476 | 477 | // Store the observer so we can disconnect it on unload 478 | this.registerEvent({ 479 | // @ts-ignore - Store observer reference for cleanup 480 | _observer: popoverObserver, 481 | // @ts-ignore - Custom cleanup method 482 | destroy: () => popoverObserver.disconnect() 483 | } as any); 484 | 485 | // Initial processing for any currently active view, once layout is ready 486 | this.app.workspace.onLayoutReady(() => { 487 | if (!this.initialLayoutReadyProcessed) { 488 | this.handleActiveViewChange(); // Process the initially open view 489 | this.initialLayoutReadyProcessed = true; 490 | } 491 | }); 492 | } 493 | 494 | /** 495 | * Called when the plugin is unloaded. 496 | * Cleans up all injected content and observers. 497 | */ 498 | async onunload() { 499 | this.app.workspace.detachLeavesOfType(VIRTUAL_CONTENT_VIEW_TYPE); 500 | this.settings.rules.forEach((rule, index) => { 501 | if (rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) { 502 | this.app.workspace.detachLeavesOfType(this.getSeparateViewId(index)); 503 | } 504 | }); 505 | this.clearAllViewsDynamicContent(); 506 | 507 | // Clean up any remaining DOM elements and components directly 508 | document.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => { 509 | const componentHolder = el as HTMLElementWithComponent; 510 | if (componentHolder.component) { 511 | componentHolder.component.unload(); 512 | } 513 | el.remove(); 514 | }); 515 | 516 | // Remove custom CSS classes applied for styling 517 | document.querySelectorAll(`.${CSS_VIRTUAL_FOOTER_CM_PADDING}`).forEach(el => el.classList.remove(CSS_VIRTUAL_FOOTER_CM_PADDING)); 518 | document.querySelectorAll(`.${CSS_VIRTUAL_FOOTER_REMOVE_FLEX}`).forEach(el => el.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX)); 519 | 520 | // WeakMaps will be garbage collected, but explicit clearing is good practice if needed. 521 | // Observers and pending injections are cleared per-view in `removeDynamicContentFromView`. 522 | this.previewObservers = new WeakMap(); 523 | this.pendingPreviewInjections = new WeakMap(); 524 | } 525 | 526 | /** 527 | * Handles changes to the active Markdown view, triggering content processing. 528 | */ 529 | private handleActiveViewChange = () => { 530 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 531 | this._processView(activeView); 532 | } 533 | 534 | /** 535 | * Checks if a MarkdownView is displayed within a popover (hover preview). 536 | * @param view The MarkdownView to check. 537 | * @returns True if the view is in a popover, false otherwise. 538 | */ 539 | private isInPopover(view: MarkdownView): boolean { 540 | // Check if the view's container element is within a popover 541 | let element: HTMLElement | null = view.containerEl; 542 | 543 | // Debug: Log the container element and its classes 544 | //console.log("VirtualContent: Checking popover for view container:", view.containerEl.className); 545 | 546 | while (element) { 547 | // Check for popover classes 548 | if (element.classList.contains('popover') && element.classList.contains('hover-popover')) { 549 | //console.log("VirtualContent: Found popover via direct popover classes"); 550 | return true; 551 | } 552 | // Also check for markdown-embed class which indicates an embedded view (often in popovers) 553 | if (element.classList.contains('markdown-embed')) { 554 | //console.log("VirtualContent: Found markdown-embed, checking for parent popover"); 555 | // If it's a markdown-embed, check if it's inside a popover 556 | let parent = element.parentElement; 557 | while (parent) { 558 | if (parent.classList.contains('popover') && parent.classList.contains('hover-popover')) { 559 | console.log("VirtualContent: Found popover via markdown-embed parent"); 560 | return true; 561 | } 562 | parent = parent.parentElement; 563 | } 564 | } 565 | element = element.parentElement; 566 | } 567 | //console.log("VirtualContent: Not a popover view"); 568 | return false; 569 | } 570 | 571 | /** 572 | * Processes any popover views that might be open but haven't been processed yet. 573 | */ 574 | private processPopoverViews(): void { 575 | // Find all popover elements in the DOM 576 | const popovers = document.querySelectorAll('.popover.hover-popover'); 577 | 578 | popovers.forEach(popover => { 579 | // Look for markdown views within each popover 580 | const markdownEmbed = popover.querySelector('.markdown-embed'); 581 | if (markdownEmbed) { 582 | //console.log("VirtualContent: Found markdown-embed in popover, processing directly"); 583 | // Process the popover content directly 584 | this.processPopoverDirectly(popover as HTMLElement); 585 | } 586 | }); 587 | } 588 | 589 | /** 590 | * Process popover content directly when we can't find the MarkdownView 591 | */ 592 | private processPopoverDirectly(popover: HTMLElement): void { 593 | console.log("VirtualContent: Processing popover directly"); 594 | 595 | // Try to extract the file path from the popover 596 | const markdownEmbed = popover.querySelector('.markdown-embed'); 597 | if (!markdownEmbed) { 598 | //console.log("VirtualContent: No markdown-embed found in popover"); 599 | return; 600 | } 601 | 602 | let filePath: string | null = null; 603 | 604 | // Method 1: Get the title from inline-title and resolve to file path 605 | const inlineTitle = popover.querySelector('.inline-title'); 606 | if (inlineTitle) { 607 | const title = inlineTitle.textContent?.trim(); 608 | //console.log("VirtualContent: Found inline-title:", title); 609 | 610 | if (title) { 611 | // Try to resolve the title to a file path using Obsidian's API 612 | const file = this.app.metadataCache.getFirstLinkpathDest(title, ''); 613 | if (file) { 614 | filePath = file.path; 615 | //console.log("VirtualContent: Resolved title to file path:", filePath); 616 | } else { 617 | // If direct resolution fails, try with .md extension 618 | const fileWithExt = this.app.metadataCache.getFirstLinkpathDest(title + '.md', ''); 619 | if (fileWithExt) { 620 | filePath = fileWithExt.path; 621 | //console.log("VirtualContent: Resolved title with .md extension to file path:", filePath); 622 | } else { 623 | //console.log("VirtualContent: Could not resolve title to file path"); 624 | } 625 | } 626 | } 627 | } 628 | 629 | //console.log("VirtualContent: Final extracted file path for direct processing:", filePath); 630 | 631 | if (filePath) { 632 | // Remove any hash fragments or block references 633 | const cleanPath = filePath.split('#')[0].split('^')[0]; 634 | //console.log("VirtualContent: Cleaned file path:", cleanPath); 635 | // Process the popover content directly 636 | this.injectContentIntoPopover(popover, cleanPath); 637 | } else { 638 | console.log("VirtualContent: Could not determine file path for popover"); 639 | // Log the DOM structure for debugging 640 | console.log("VirtualContent: Popover DOM structure:", popover.innerHTML.substring(0, 1000)); 641 | } 642 | } 643 | 644 | /** 645 | * Directly inject virtual content into a popover 646 | */ 647 | private async injectContentIntoPopover(popover: HTMLElement, filePath: string): Promise { 648 | //console.log("VirtualContent: Directly injecting content into popover for:", filePath); 649 | 650 | try { 651 | const applicableRulesWithContent = await this._getApplicableRulesAndContent(filePath); 652 | 653 | // Filter rules based on popover visibility setting 654 | const filteredRules = applicableRulesWithContent.filter(({ rule }) => { 655 | return rule.showInPopover !== false; // Show by default unless explicitly disabled 656 | }); 657 | 658 | if (filteredRules.length === 0) { 659 | //console.log("VirtualContent: No applicable rules for popover"); 660 | return; 661 | } 662 | 663 | // Find the markdown embed container 664 | const markdownEmbed = popover.querySelector('.markdown-embed'); 665 | if (!markdownEmbed) return; 666 | 667 | // Group content by render location 668 | const headerContentGroups: { normal: string[], aboveProperties: string[] } = { normal: [], aboveProperties: [] }; 669 | const footerContentGroups: { normal: string[], aboveBacklinks: string[] } = { normal: [], aboveBacklinks: [] }; 670 | const contentSeparator = "\n\n"; 671 | 672 | for (const { rule, contentText } of filteredRules) { 673 | if (!contentText || contentText.trim() === "") continue; 674 | 675 | if (rule.renderLocation === RenderLocation.Header) { 676 | if (rule.renderAboveProperties) { 677 | headerContentGroups.aboveProperties.push(contentText); 678 | } else { 679 | headerContentGroups.normal.push(contentText); 680 | } 681 | } else if (rule.renderLocation === RenderLocation.Footer) { 682 | // For popovers, treat all footer content the same regardless of renderAboveBacklinks setting 683 | // since backlinks don't exist in popovers 684 | footerContentGroups.normal.push(contentText); 685 | } 686 | // Skip sidebar rules for popovers 687 | } 688 | 689 | // Inject header content 690 | if (headerContentGroups.normal.length > 0) { 691 | const combinedContent = headerContentGroups.normal.join(contentSeparator); 692 | await this.injectContentIntoPopoverSection(markdownEmbed as HTMLElement, combinedContent, 'header', false, filePath); 693 | } 694 | 695 | if (headerContentGroups.aboveProperties.length > 0) { 696 | const combinedContent = headerContentGroups.aboveProperties.join(contentSeparator); 697 | await this.injectContentIntoPopoverSection(markdownEmbed as HTMLElement, combinedContent, 'header', true, filePath); 698 | } 699 | 700 | // Inject footer content 701 | if (footerContentGroups.normal.length > 0) { 702 | const combinedContent = footerContentGroups.normal.join(contentSeparator); 703 | await this.injectContentIntoPopoverSection(markdownEmbed as HTMLElement, combinedContent, 'footer', false, filePath); 704 | } 705 | 706 | } catch (error) { 707 | console.error("VirtualContent: Error processing popover directly:", error); 708 | } 709 | } 710 | 711 | /** 712 | * Inject content into a specific section of a popover 713 | */ 714 | private async injectContentIntoPopoverSection( 715 | container: HTMLElement, 716 | content: string, 717 | location: 'header' | 'footer', 718 | special: boolean, 719 | filePath: string 720 | ): Promise { 721 | const isHeader = location === 'header'; 722 | const cssClass = isHeader ? CSS_HEADER_GROUP_ELEMENT : CSS_FOOTER_GROUP_ELEMENT; 723 | const specialClass = isHeader ? 'virtual-footer-above-properties' : 'virtual-footer-above-backlinks'; 724 | 725 | // Create new content container 726 | const groupDiv = document.createElement('div') as HTMLElementWithComponent; 727 | groupDiv.className = `${CSS_DYNAMIC_CONTENT_ELEMENT} ${cssClass}`; 728 | if (special) { 729 | groupDiv.classList.add(specialClass); 730 | } 731 | 732 | // Add additional CSS classes for consistency with main view injection 733 | if (isHeader) { 734 | groupDiv.classList.add(CSS_HEADER_RENDERED_CONTENT); 735 | } else { 736 | groupDiv.classList.add(CSS_FOOTER_RENDERED_CONTENT); 737 | if (special) { 738 | groupDiv.classList.add(CSS_ABOVE_BACKLINKS); 739 | } 740 | } 741 | 742 | // Create component for lifecycle management 743 | const component = new Component(); 744 | component.load(); 745 | groupDiv.component = component; 746 | 747 | try { 748 | // Render the content 749 | await MarkdownRenderer.render(this.app, content, groupDiv, filePath, component); 750 | this.attachInternalLinkHandlers(groupDiv, filePath, component); 751 | 752 | // Use the same logic as main view injection - find target parent using standard selectors 753 | let targetParent: HTMLElement | null = null; 754 | 755 | // First, detect if we're in editing mode or preview mode 756 | // Check if the popover container has the is-editing class 757 | const popoverContainer = container.closest('.popover.hover-popover'); 758 | const isEditingMode = popoverContainer?.classList.contains('is-editing') || 759 | container.querySelector(SELECTOR_EDITOR_SIZER) !== null; 760 | //console.log(`VirtualContent: Popover is in ${isEditingMode ? 'editing' : 'preview'} mode`); 761 | 762 | if (isHeader) { 763 | if (special) { 764 | // Try to find metadata container first (same as main view logic) 765 | targetParent = container.querySelector(SELECTOR_METADATA_CONTAINER); 766 | } 767 | // If no metadata container or special is false, use appropriate header area 768 | if (!targetParent) { 769 | if (isEditingMode) { 770 | // In editing mode, we need to find the content container and insert before it 771 | const cmContentContainer = container.querySelector(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER); 772 | if (cmContentContainer?.parentElement) { 773 | // We'll handle the insertion differently for editing mode headers 774 | targetParent = cmContentContainer.parentElement; 775 | } 776 | } else { 777 | // In preview mode, use regular header area 778 | targetParent = container.querySelector(SELECTOR_PREVIEW_HEADER_AREA); 779 | } 780 | } 781 | } else { // Footer 782 | if (special) { 783 | // Try to find embedded backlinks first (same as main view logic) 784 | targetParent = container.querySelector(SELECTOR_EMBEDDED_BACKLINKS); 785 | } 786 | // If no backlinks or special is false, use appropriate footer area 787 | if (!targetParent) { 788 | if (isEditingMode) { 789 | // In editing mode, use editor sizer 790 | targetParent = container.querySelector(SELECTOR_EDITOR_SIZER); 791 | } else { 792 | // In preview mode, try standard footer area first 793 | targetParent = container.querySelector(SELECTOR_PREVIEW_FOOTER_AREA); 794 | // Fallback for popovers: use markdown-preview-sizer if standard footer selectors don't exist 795 | if (!targetParent) { 796 | targetParent = container.querySelector('.markdown-preview-sizer.markdown-preview-section'); 797 | } 798 | } 799 | } 800 | } 801 | 802 | if (targetParent) { 803 | // Remove existing content of this type (same cleanup logic as main view) 804 | if (isHeader && special) { 805 | // Remove existing header content above properties 806 | container.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => { 807 | const holder = el as HTMLElementWithComponent; 808 | holder.component?.unload(); 809 | el.remove(); 810 | }); 811 | } else if (isHeader && !special) { 812 | // Remove existing normal header content 813 | targetParent.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}:not(.virtual-footer-above-properties)`).forEach(el => { 814 | const holder = el as HTMLElementWithComponent; 815 | holder.component?.unload(); 816 | el.remove(); 817 | }); 818 | } else if (!isHeader && special) { 819 | // Remove existing footer content above backlinks 820 | container.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => { 821 | const holder = el as HTMLElementWithComponent; 822 | holder.component?.unload(); 823 | el.remove(); 824 | }); 825 | } else if (!isHeader && !special) { 826 | // Remove existing normal footer content 827 | targetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}:not(.virtual-footer-above-backlinks)`).forEach(el => { 828 | const holder = el as HTMLElementWithComponent; 829 | holder.component?.unload(); 830 | el.remove(); 831 | }); 832 | } 833 | 834 | // Insert using mode-specific logic 835 | if (isHeader && !special) { 836 | if (isEditingMode && targetParent.querySelector(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER)) { 837 | // For editing mode headers, insert before the content container 838 | const cmContentContainer = targetParent.querySelector(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER); 839 | if (cmContentContainer) { 840 | targetParent.insertBefore(groupDiv, cmContentContainer); 841 | } else { 842 | targetParent.appendChild(groupDiv); 843 | } 844 | } else { 845 | // For preview mode headers, append to header area 846 | targetParent.appendChild(groupDiv); 847 | } 848 | } else if (!isHeader && !special) { 849 | // For footer content 850 | if (isEditingMode) { 851 | // In editing mode, append to editor sizer 852 | targetParent.appendChild(groupDiv); 853 | } else { 854 | // In preview mode, check if we're using the popover fallback selector 855 | if (targetParent.matches('.markdown-preview-sizer.markdown-preview-section')) { 856 | // Insert after the markdown-preview-sizer, not inside it 857 | targetParent.parentElement?.insertBefore(groupDiv, targetParent.nextSibling); 858 | } else { 859 | // For regular footer areas, append inside 860 | targetParent.appendChild(groupDiv); 861 | } 862 | } 863 | } else { 864 | // Insert before properties or backlinks (same as main view) 865 | targetParent.parentElement?.insertBefore(groupDiv, targetParent); 866 | } 867 | 868 | //console.log(`VirtualContent: Successfully injected ${location} content into popover using standard selectors`); 869 | } else { 870 | //console.log(`VirtualContent: Target parent not found for ${location} injection in popover, falling back to container`); 871 | // Fallback to simple container injection if selectors don't match 872 | if (isHeader) { 873 | container.insertBefore(groupDiv, container.firstChild); 874 | } else { 875 | container.appendChild(groupDiv); 876 | } 877 | } 878 | 879 | } catch (error) { 880 | console.error("VirtualContent: Error rendering content for popover:", error); 881 | component.unload(); 882 | } 883 | } 884 | 885 | /** 886 | * Processes a given Markdown view to inject or update dynamic content. 887 | * @param view The MarkdownView to process. 888 | */ 889 | private async _processView(view: MarkdownView | null): Promise { 890 | if (!view || !view.file) { 891 | // If 'refresh on focus' is off, we clear the sidebar when focus is lost from a markdown file. 892 | // If it's on, we only clear the sidebar if the last markdown file has been closed, 893 | // preserving the content when switching to non-markdown views. 894 | if (!this.settings.refreshOnFileOpen || this.app.workspace.getLeavesOfType('markdown').length === 0) { 895 | this.lastSidebarContent = null; 896 | this.lastSeparateTabContents.clear(); 897 | this.updateAllSidebarViews(); 898 | } 899 | return; // No view or file to process 900 | } 901 | 902 | // Check if this is a popover view 903 | const isPopoverView = this.isInPopover(view); 904 | 905 | await this.removeDynamicContentFromView(view); // Clear existing content first 906 | const applicableRulesWithContent = await this._getApplicableRulesAndContent(view.file.path); 907 | 908 | // Filter rules based on popover visibility setting 909 | const filteredRules = applicableRulesWithContent.filter(({ rule }) => { 910 | if (isPopoverView && rule.showInPopover === false) { 911 | return false; // Skip this rule in popover views 912 | } 913 | return true; 914 | }); 915 | 916 | const viewState = view.getState(); 917 | let combinedHeaderText = ""; 918 | let combinedFooterText = ""; 919 | let combinedSidebarText = ""; 920 | let hasFooterRule = false; 921 | const contentSeparator = "\n\n"; // Separator between content from multiple rules 922 | this.lastSeparateTabContents.clear(); 923 | 924 | // Combine content from all applicable rules, grouping by render location and positioning 925 | const headerContentGroups: { normal: string[], aboveProperties: string[] } = { normal: [], aboveProperties: [] }; 926 | const footerContentGroups: { normal: string[], aboveBacklinks: string[] } = { normal: [], aboveBacklinks: [] }; 927 | 928 | for (const { rule, contentText, index } of filteredRules) { 929 | if (!contentText || contentText.trim() === "") continue; // Skip empty content 930 | 931 | if (rule.renderLocation === RenderLocation.Header) { 932 | if (rule.renderAboveProperties) { 933 | headerContentGroups.aboveProperties.push(contentText); 934 | } else { 935 | headerContentGroups.normal.push(contentText); 936 | } 937 | } else if (rule.renderLocation === RenderLocation.Footer) { 938 | if (rule.renderAboveBacklinks) { 939 | footerContentGroups.aboveBacklinks.push(contentText); 940 | } else { 941 | footerContentGroups.normal.push(contentText); 942 | } 943 | hasFooterRule = true; 944 | } else if (rule.renderLocation === RenderLocation.Sidebar) { 945 | if (rule.showInSeparateTab) { 946 | const viewId = this.getSeparateViewId(index); 947 | const existingContent = this.lastSeparateTabContents.get(viewId)?.content || ""; 948 | this.lastSeparateTabContents.set(viewId, { 949 | content: (existingContent ? existingContent + contentSeparator : "") + contentText, 950 | sourcePath: view.file.path 951 | }); 952 | } else { 953 | combinedSidebarText += (combinedSidebarText ? contentSeparator : "") + contentText; 954 | } 955 | } 956 | } 957 | 958 | // Store sidebar content and update the view 959 | this.lastSidebarContent = { content: combinedSidebarText, sourcePath: view.file.path }; 960 | this.updateAllSidebarViews(); 961 | 962 | // Determine if we should render based on view mode and settings 963 | const isLivePreview = viewState.mode === 'source' && !viewState.source; 964 | const isSourceMode = viewState.mode === 'source' && viewState.source; 965 | const isReadingMode = viewState.mode === 'preview'; 966 | 967 | const shouldRenderInSource = isSourceMode && this.settings.renderInSourceMode; 968 | const shouldRenderInLivePreview = isLivePreview; 969 | const shouldRenderInReading = isReadingMode; 970 | 971 | // Apply specific styles for Live Preview footers if needed 972 | if ((shouldRenderInLivePreview || shouldRenderInSource) && hasFooterRule) { 973 | this.applyLivePreviewFooterStyles(view); 974 | } 975 | 976 | let pendingHeaderDiv: HTMLElementWithComponent | null = null; 977 | let pendingFooterDiv: HTMLElementWithComponent | null = null; 978 | let pendingHeaderAbovePropertiesDiv: HTMLElementWithComponent | null = null; 979 | let pendingFooterAboveBacklinksDiv: HTMLElementWithComponent | null = null; 980 | 981 | // Render and inject content based on view mode, handling each positioning group separately 982 | if (shouldRenderInReading || shouldRenderInLivePreview || shouldRenderInSource) { 983 | // Handle normal header content 984 | if (headerContentGroups.normal.length > 0) { 985 | const combinedContent = headerContentGroups.normal.join(contentSeparator); 986 | const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Header, false); 987 | if (result && shouldRenderInReading) { 988 | pendingHeaderDiv = result; 989 | } 990 | } 991 | 992 | // Handle header content above properties 993 | if (headerContentGroups.aboveProperties.length > 0) { 994 | const combinedContent = headerContentGroups.aboveProperties.join(contentSeparator); 995 | const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Header, true); 996 | if (result && shouldRenderInReading) { 997 | pendingHeaderAbovePropertiesDiv = result; 998 | } 999 | } 1000 | 1001 | // Handle normal footer content 1002 | if (footerContentGroups.normal.length > 0) { 1003 | const combinedContent = footerContentGroups.normal.join(contentSeparator); 1004 | const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Footer, false, false); 1005 | if (result && shouldRenderInReading) { 1006 | pendingFooterDiv = result; 1007 | } 1008 | } 1009 | 1010 | // Handle footer content above backlinks 1011 | if (footerContentGroups.aboveBacklinks.length > 0) { 1012 | const combinedContent = footerContentGroups.aboveBacklinks.join(contentSeparator); 1013 | const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Footer, false, true); 1014 | if (result && shouldRenderInReading) { 1015 | pendingFooterAboveBacklinksDiv = result; 1016 | } 1017 | } 1018 | } 1019 | 1020 | // If any content is pending for preview mode, set up an observer 1021 | if (pendingHeaderDiv || pendingFooterDiv || pendingHeaderAbovePropertiesDiv || pendingFooterAboveBacklinksDiv) { 1022 | this.pendingPreviewInjections.set(view, { 1023 | headerDiv: pendingHeaderDiv || undefined, 1024 | footerDiv: pendingFooterDiv || undefined, 1025 | headerAbovePropertiesDiv: pendingHeaderAbovePropertiesDiv || undefined, 1026 | footerAboveBacklinksDiv: pendingFooterAboveBacklinksDiv || undefined, 1027 | filePath: view.file.path, 1028 | }); 1029 | this.ensurePreviewObserver(view); 1030 | } 1031 | } 1032 | 1033 | /** 1034 | * Renders combined Markdown content and injects it into the specified location in the view. 1035 | * @param view The MarkdownView to inject content into. 1036 | * @param combinedContentText The combined Markdown string to render. 1037 | * @param renderLocation Specifies whether to render in the header or footer. 1038 | * @param renderAboveProperties For header content, whether to render above properties section. 1039 | * @param renderAboveBacklinks For footer content, whether to render above backlinks section. 1040 | * @returns The rendered HTMLElement if injection is deferred (for preview mode), otherwise null. 1041 | */ 1042 | private async renderAndInjectGroupedContent( 1043 | view: MarkdownView, 1044 | combinedContentText: string, 1045 | renderLocation: RenderLocation, 1046 | renderAboveProperties: boolean = false, 1047 | renderAboveBacklinks: boolean = false 1048 | ): Promise { 1049 | if (!combinedContentText || combinedContentText.trim() === "") { 1050 | return null; 1051 | } 1052 | 1053 | const isRenderInHeader = renderLocation === RenderLocation.Header; 1054 | const sourcePath = view.file?.path || ''; // For MarkdownRenderer context 1055 | 1056 | // Create container div for the content 1057 | const groupDiv = document.createElement('div') as HTMLElementWithComponent; 1058 | groupDiv.className = CSS_DYNAMIC_CONTENT_ELEMENT; // Base class for all injected content 1059 | groupDiv.classList.add( 1060 | isRenderInHeader ? CSS_HEADER_GROUP_ELEMENT : CSS_FOOTER_GROUP_ELEMENT, 1061 | isRenderInHeader ? CSS_HEADER_RENDERED_CONTENT : CSS_FOOTER_RENDERED_CONTENT 1062 | ); 1063 | 1064 | // Add the above-backlinks class for footer content when the setting is enabled 1065 | if (!isRenderInHeader && renderAboveBacklinks) { 1066 | groupDiv.classList.add(CSS_ABOVE_BACKLINKS); 1067 | groupDiv.classList.add('virtual-footer-above-backlinks'); 1068 | } 1069 | 1070 | // Add the above-properties class for header content when the setting is enabled 1071 | if (isRenderInHeader && renderAboveProperties) { 1072 | groupDiv.classList.add('virtual-footer-above-properties'); 1073 | } 1074 | 1075 | // Create and manage an Obsidian Component for the lifecycle of this content 1076 | const component = new Component(); 1077 | component.load(); 1078 | groupDiv.component = component; 1079 | 1080 | // Try to render the Markdown content with retry logic for early load errors 1081 | try { 1082 | await MarkdownRenderer.render(this.app, combinedContentText, groupDiv, sourcePath, component); 1083 | } catch (error) { 1084 | console.log("VirtualFooter: Error during initial render, will retry after delay:", error); 1085 | 1086 | // Add a placeholder while waiting to retry 1087 | const placeholderEl = groupDiv.createEl("div", { cls: "virtual-footer-loading" }); 1088 | placeholderEl.createEl("p", { text: "Loading virtual content..." }); 1089 | 1090 | // Schedule a retry after a delay to allow other plugins to initialize 1091 | setTimeout(async () => { 1092 | try { 1093 | placeholderEl.remove(); 1094 | await MarkdownRenderer.render(this.app, combinedContentText, groupDiv, sourcePath, component); 1095 | this.attachInternalLinkHandlers(groupDiv, sourcePath, component); 1096 | } catch (secondError) { 1097 | console.error("VirtualFooter: Failed to render content after retry:", secondError); 1098 | const errorEl = groupDiv.createEl("div", { cls: "virtual-footer-error" }); 1099 | errorEl.createEl("p", { text: "Error rendering virtual content. Please reload the page or check the content for errors." }); 1100 | } 1101 | }, 2000); // 2 second delay 1102 | } 1103 | 1104 | let injectionSuccessful = false; 1105 | const viewState = view.getState(); 1106 | 1107 | // Inject based on view mode and render location 1108 | if (viewState.mode === 'preview') { // Reading mode 1109 | const previewContentParent = view.previewMode.containerEl; 1110 | let targetParent: HTMLElement | null = null; 1111 | 1112 | if (isRenderInHeader) { 1113 | if (renderAboveProperties) { 1114 | // Try to find metadata container first 1115 | targetParent = previewContentParent.querySelector(SELECTOR_METADATA_CONTAINER); 1116 | } 1117 | // If no metadata container or renderAboveProperties is false, use regular header 1118 | if (!targetParent) { 1119 | targetParent = previewContentParent.querySelector(SELECTOR_PREVIEW_HEADER_AREA); 1120 | } 1121 | } else { // Footer 1122 | if (renderAboveBacklinks) { 1123 | // Try to find embedded backlinks first 1124 | targetParent = previewContentParent.querySelector(SELECTOR_EMBEDDED_BACKLINKS); 1125 | } 1126 | // If no backlinks or renderAboveBacklinks is false, use regular footer 1127 | if (!targetParent) { 1128 | targetParent = previewContentParent.querySelector(SELECTOR_PREVIEW_FOOTER_AREA); 1129 | } 1130 | } 1131 | 1132 | if (targetParent) { 1133 | // Ensure idempotency: remove any existing content of this type before adding new 1134 | if (isRenderInHeader && renderAboveProperties) { 1135 | // Remove existing header content above properties 1136 | view.previewMode.containerEl.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => { 1137 | const holder = el as HTMLElementWithComponent; 1138 | holder.component?.unload(); 1139 | el.remove(); 1140 | }); 1141 | } else if (isRenderInHeader && !renderAboveProperties) { 1142 | // Remove existing normal header content 1143 | targetParent.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}:not(.virtual-footer-above-properties)`).forEach(el => { 1144 | const holder = el as HTMLElementWithComponent; 1145 | holder.component?.unload(); 1146 | el.remove(); 1147 | }); 1148 | } else if (!isRenderInHeader && renderAboveBacklinks) { 1149 | // Remove existing footer content above backlinks 1150 | view.previewMode.containerEl.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => { 1151 | const holder = el as HTMLElementWithComponent; 1152 | holder.component?.unload(); 1153 | el.remove(); 1154 | }); 1155 | } else if (!isRenderInHeader && !renderAboveBacklinks) { 1156 | // Remove existing normal footer content 1157 | targetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}:not(.virtual-footer-above-backlinks)`).forEach(el => { 1158 | const holder = el as HTMLElementWithComponent; 1159 | holder.component?.unload(); 1160 | el.remove(); 1161 | }); 1162 | } 1163 | 1164 | if (isRenderInHeader && !renderAboveProperties) { 1165 | targetParent.appendChild(groupDiv); 1166 | } else if (!isRenderInHeader && !renderAboveBacklinks) { 1167 | targetParent.appendChild(groupDiv); 1168 | } else { 1169 | // Insert before properties or backlinks 1170 | targetParent.parentElement?.insertBefore(groupDiv, targetParent); 1171 | } 1172 | injectionSuccessful = true; 1173 | } 1174 | } else if (viewState.mode === 'source') { // Live Preview or Source mode 1175 | if (isRenderInHeader) { 1176 | let targetParent: HTMLElement | null = null; 1177 | 1178 | if (renderAboveProperties) { 1179 | // Try to find metadata container first in live preview 1180 | targetParent = view.containerEl.querySelector(SELECTOR_METADATA_CONTAINER); 1181 | } 1182 | 1183 | // If no metadata container or renderAboveProperties is false, use content container 1184 | if (!targetParent) { 1185 | const cmContentContainer = view.containerEl.querySelector(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER); 1186 | if (cmContentContainer?.parentElement) { 1187 | // Ensure idempotency: remove existing normal header content 1188 | cmContentContainer.parentElement.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}:not(.virtual-footer-above-properties)`).forEach(el => { 1189 | const holder = el as HTMLElementWithComponent; 1190 | holder.component?.unload(); 1191 | el.remove(); 1192 | }); 1193 | cmContentContainer.parentElement.insertBefore(groupDiv, cmContentContainer); 1194 | injectionSuccessful = true; 1195 | } 1196 | } else { 1197 | // Ensure idempotency: remove existing header content above properties 1198 | view.containerEl.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => { 1199 | const holder = el as HTMLElementWithComponent; 1200 | holder.component?.unload(); 1201 | el.remove(); 1202 | }); 1203 | // Insert before properties 1204 | targetParent.parentElement?.insertBefore(groupDiv, targetParent); 1205 | injectionSuccessful = true; 1206 | } 1207 | } else { // Footer in Live Preview or Source mode 1208 | let targetParent: HTMLElement | null = null; 1209 | 1210 | if (renderAboveBacklinks) { 1211 | // Try to find embedded backlinks first in live preview 1212 | targetParent = view.containerEl.querySelector(SELECTOR_EMBEDDED_BACKLINKS); 1213 | } 1214 | 1215 | // If no backlinks or renderAboveBacklinks is false, use regular editor sizer 1216 | if (!targetParent) { 1217 | targetParent = view.containerEl.querySelector(SELECTOR_EDITOR_SIZER); 1218 | } 1219 | 1220 | if (targetParent) { 1221 | // Ensure idempotency: remove existing content of the appropriate type 1222 | if (renderAboveBacklinks) { 1223 | // Remove existing footer content above backlinks 1224 | view.containerEl.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => { 1225 | const holder = el as HTMLElementWithComponent; 1226 | holder.component?.unload(); 1227 | el.remove(); 1228 | }); 1229 | } else { 1230 | // Remove existing normal footer content 1231 | targetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}:not(.virtual-footer-above-backlinks)`).forEach(el => { 1232 | const holder = el as HTMLElementWithComponent; 1233 | holder.component?.unload(); 1234 | el.remove(); 1235 | }); 1236 | } 1237 | 1238 | if (!renderAboveBacklinks || targetParent.matches(SELECTOR_EDITOR_SIZER)) { 1239 | targetParent.appendChild(groupDiv); 1240 | } else { 1241 | // Insert before backlinks 1242 | targetParent.parentElement?.insertBefore(groupDiv, targetParent); 1243 | } 1244 | injectionSuccessful = true; 1245 | } 1246 | } 1247 | } 1248 | 1249 | if (injectionSuccessful) { 1250 | this.attachInternalLinkHandlers(groupDiv, sourcePath, component); 1251 | return null; // Injection successful, no need to return element 1252 | } else { 1253 | // If injection failed in preview mode, it might be because the target DOM isn't ready. 1254 | // Return the div to be handled by the MutationObserver. 1255 | if (viewState.mode === 'preview') { 1256 | console.log(`VirtualFooter: Deferring injection for ${renderLocation} in preview mode. Target not found yet.`); 1257 | return groupDiv; // Return for deferred injection 1258 | } else { 1259 | // For other modes, if injection fails, unload component and log warning. 1260 | component.unload(); 1261 | console.warn(`VirtualFooter: Failed to find injection point for dynamic content group (${renderLocation}). View mode: ${viewState.mode}.`); 1262 | return null; 1263 | } 1264 | } 1265 | } 1266 | 1267 | /** 1268 | * Ensures a MutationObserver is set up for a view in preview mode to handle deferred content injection. 1269 | * The observer watches for the appearance of target DOM elements and is careful not to act on stale data. 1270 | * @param view The MarkdownView to observe. 1271 | */ 1272 | private ensurePreviewObserver(view: MarkdownView): void { 1273 | if (this.previewObservers.has(view) || !view.file || !view.previewMode?.containerEl) { 1274 | return; // Observer already exists, or view/file/container not ready 1275 | } 1276 | 1277 | const observerPath = view.file.path; // Path this observer is responsible for. 1278 | 1279 | const observer = new MutationObserver((_mutations) => { 1280 | const pending = this.pendingPreviewInjections.get(view); 1281 | 1282 | // This observer is stale and should self-destruct if: 1283 | // 1. The view has no file or has navigated to a different file. 1284 | // 2. There are no pending injections for this view. 1285 | // 3. The pending injections are for a different file. 1286 | if (!view.file || view.file.path !== observerPath || !pending || pending.filePath !== observerPath) { 1287 | observer.disconnect(); 1288 | // Only remove this specific observer instance from the map 1289 | if (this.previewObservers.get(view) === observer) { 1290 | this.previewObservers.delete(view); 1291 | } 1292 | return; 1293 | } 1294 | 1295 | // If there's nothing left to inject, clean up and disconnect. 1296 | if (!pending.headerDiv && !pending.footerDiv && !pending.headerAbovePropertiesDiv && !pending.footerAboveBacklinksDiv) { 1297 | observer.disconnect(); 1298 | if (this.previewObservers.get(view) === observer) { 1299 | this.previewObservers.delete(view); 1300 | } 1301 | this.pendingPreviewInjections.delete(view); 1302 | return; 1303 | } 1304 | 1305 | let allResolved = true; 1306 | const sourcePath = view.file.path; 1307 | 1308 | // Attempt to inject pending header content 1309 | if (pending.headerDiv) { 1310 | const headerTargetParent = view.previewMode.containerEl.querySelector(SELECTOR_PREVIEW_HEADER_AREA); 1311 | if (headerTargetParent) { 1312 | // Ensure idempotency: remove any existing header content before adding new. 1313 | headerTargetParent.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}`).forEach(el => { 1314 | const holder = el as HTMLElementWithComponent; 1315 | holder.component?.unload(); 1316 | el.remove(); 1317 | }); 1318 | headerTargetParent.appendChild(pending.headerDiv); 1319 | if (pending.headerDiv.component) { 1320 | this.attachInternalLinkHandlers(pending.headerDiv, sourcePath, pending.headerDiv.component); 1321 | } 1322 | delete pending.headerDiv; // Injection successful 1323 | } else { 1324 | allResolved = false; // Target not yet available 1325 | } 1326 | } 1327 | 1328 | // Attempt to inject pending header content above properties 1329 | if (pending.headerAbovePropertiesDiv) { 1330 | const headerTargetParent = view.previewMode.containerEl.querySelector(SELECTOR_METADATA_CONTAINER); 1331 | if (headerTargetParent) { 1332 | // Ensure idempotency: remove any existing content of this type 1333 | view.previewMode.containerEl.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => { 1334 | const holder = el as HTMLElementWithComponent; 1335 | holder.component?.unload(); 1336 | el.remove(); 1337 | }); 1338 | // Add a class to distinguish this from regular header content 1339 | pending.headerAbovePropertiesDiv.classList.add('virtual-footer-above-properties'); 1340 | // Insert before properties 1341 | headerTargetParent.parentElement?.insertBefore(pending.headerAbovePropertiesDiv, headerTargetParent); 1342 | if (pending.headerAbovePropertiesDiv.component) { 1343 | this.attachInternalLinkHandlers(pending.headerAbovePropertiesDiv, sourcePath, pending.headerAbovePropertiesDiv.component); 1344 | } 1345 | delete pending.headerAbovePropertiesDiv; // Injection successful 1346 | } else { 1347 | allResolved = false; // Target not yet available 1348 | } 1349 | } 1350 | 1351 | // Attempt to inject pending footer content 1352 | if (pending.footerDiv) { 1353 | const footerTargetParent = view.previewMode.containerEl.querySelector(SELECTOR_PREVIEW_FOOTER_AREA); 1354 | if (footerTargetParent) { 1355 | // Ensure idempotency: remove any existing footer content before adding new. 1356 | footerTargetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}`).forEach(el => { 1357 | const holder = el as HTMLElementWithComponent; 1358 | holder.component?.unload(); 1359 | el.remove(); 1360 | }); 1361 | footerTargetParent.appendChild(pending.footerDiv); 1362 | if (pending.footerDiv.component) { 1363 | this.attachInternalLinkHandlers(pending.footerDiv, sourcePath, pending.footerDiv.component); 1364 | } 1365 | delete pending.footerDiv; // Injection successful 1366 | } else { 1367 | allResolved = false; // Target not yet available 1368 | } 1369 | } 1370 | 1371 | // Attempt to inject pending footer content above backlinks 1372 | if (pending.footerAboveBacklinksDiv) { 1373 | const footerTargetParent = view.previewMode.containerEl.querySelector(SELECTOR_EMBEDDED_BACKLINKS); 1374 | if (footerTargetParent) { 1375 | // Ensure idempotency: remove any existing content of this type 1376 | view.previewMode.containerEl.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => { 1377 | const holder = el as HTMLElementWithComponent; 1378 | holder.component?.unload(); 1379 | el.remove(); 1380 | }); 1381 | // Add a class to distinguish this from regular footer content 1382 | pending.footerAboveBacklinksDiv.classList.add('virtual-footer-above-backlinks'); 1383 | // Insert before backlinks 1384 | footerTargetParent.parentElement?.insertBefore(pending.footerAboveBacklinksDiv, footerTargetParent); 1385 | if (pending.footerAboveBacklinksDiv.component) { 1386 | this.attachInternalLinkHandlers(pending.footerAboveBacklinksDiv, sourcePath, pending.footerAboveBacklinksDiv.component); 1387 | } 1388 | delete pending.footerAboveBacklinksDiv; // Injection successful 1389 | } else { 1390 | allResolved = false; // Target not yet available 1391 | } 1392 | } 1393 | 1394 | // If all pending injections are resolved, disconnect the observer 1395 | if (allResolved) { 1396 | observer.disconnect(); 1397 | if (this.previewObservers.get(view) === observer) { 1398 | this.previewObservers.delete(view); 1399 | } 1400 | this.pendingPreviewInjections.delete(view); 1401 | } 1402 | }); 1403 | 1404 | // Start observing the preview container for child and subtree changes 1405 | observer.observe(view.previewMode.containerEl, { childList: true, subtree: true }); 1406 | this.previewObservers.set(view, observer); 1407 | } 1408 | 1409 | /** 1410 | * Applies CSS classes to adjust CodeMirror (Live Preview) layout for footer content. 1411 | * @param view The MarkdownView in Live Preview mode. 1412 | */ 1413 | private applyLivePreviewFooterStyles(view: MarkdownView): void { 1414 | const contentEl = view.containerEl.querySelector(SELECTOR_EDITOR_CONTENT_AREA); 1415 | const containerEl = view.containerEl.querySelector(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT); 1416 | contentEl?.classList.add(CSS_VIRTUAL_FOOTER_CM_PADDING); 1417 | containerEl?.classList.add(CSS_VIRTUAL_FOOTER_REMOVE_FLEX); 1418 | } 1419 | 1420 | /** 1421 | * Removes CSS classes used for Live Preview footer layout adjustments. 1422 | * @param viewOrContainer The MarkdownView or a specific HTMLElement container. 1423 | */ 1424 | private removeLivePreviewFooterStyles(viewOrContainer: MarkdownView | HTMLElement): void { 1425 | const container = viewOrContainer instanceof MarkdownView ? viewOrContainer.containerEl : viewOrContainer; 1426 | const contentEl = container.querySelector(SELECTOR_EDITOR_CONTENT_AREA); 1427 | const containerEl = container.querySelector(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT); 1428 | contentEl?.classList.remove(CSS_VIRTUAL_FOOTER_CM_PADDING); 1429 | containerEl?.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX); 1430 | } 1431 | 1432 | /** 1433 | * Removes all plugin-injected DOM elements from a given container. 1434 | * @param containerEl The HTMLElement to search within. 1435 | */ 1436 | private async removeInjectedContentDOM(containerEl: HTMLElement): Promise { 1437 | containerEl.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => { 1438 | const componentHolder = el as HTMLElementWithComponent; 1439 | if (componentHolder.component) { 1440 | componentHolder.component.unload(); // Unload associated Obsidian component 1441 | } 1442 | el.remove(); // Remove the element from DOM 1443 | }); 1444 | } 1445 | 1446 | /** 1447 | * Removes all dynamic content, styles, and observers associated with a specific view. 1448 | * @param view The MarkdownView to clean up. 1449 | */ 1450 | private async removeDynamicContentFromView(view: MarkdownView): Promise { 1451 | this.removeLivePreviewFooterStyles(view); 1452 | await this.removeInjectedContentDOM(view.containerEl); 1453 | 1454 | // Disconnect and remove observer for this view 1455 | const observer = this.previewObservers.get(view); 1456 | if (observer) { 1457 | observer.disconnect(); 1458 | this.previewObservers.delete(view); 1459 | } 1460 | 1461 | // Clean up any pending injections for this view 1462 | const pending = this.pendingPreviewInjections.get(view); 1463 | if (pending) { 1464 | pending.headerDiv?.component?.unload(); 1465 | pending.footerDiv?.component?.unload(); 1466 | this.pendingPreviewInjections.delete(view); 1467 | } 1468 | } 1469 | 1470 | /** 1471 | * Clears dynamic content from all currently open Markdown views. 1472 | * Typically used during plugin unload or when global settings change significantly. 1473 | */ 1474 | private clearAllViewsDynamicContent(): void { 1475 | this.app.workspace.getLeavesOfType('markdown').forEach(leaf => { 1476 | if (leaf.view instanceof MarkdownView) { 1477 | this.removeDynamicContentFromView(leaf.view); 1478 | } 1479 | }); 1480 | // Also clear sidebar 1481 | this.lastSidebarContent = null; 1482 | this.lastSeparateTabContents.clear(); 1483 | this.updateAllSidebarViews(); 1484 | } 1485 | 1486 | /** 1487 | * Determines which rules apply to a given file path and fetches their content. 1488 | * @param filePath The path of the file to check against rules. 1489 | * @returns A promise that resolves to an array of objects, each containing an applicable rule and its content. 1490 | */ 1491 | private async _getApplicableRulesAndContent(filePath: string): Promise> { 1492 | const allApplicable: Array<{ rule: Rule; contentText: string; index: number }> = []; 1493 | const abstractFile = this.app.vault.getAbstractFileByPath(filePath); 1494 | 1495 | if (!(abstractFile instanceof TFile)) { 1496 | return []; // Not a valid file 1497 | } 1498 | const file: TFile = abstractFile; 1499 | let fileTags: string[] | null = null; // Lazily loaded 1500 | const fileCache = this.app.metadataCache.getFileCache(file); 1501 | 1502 | // Pre-fetch tags if any tag-based rules exist and are enabled 1503 | const hasEnabledTagRule = this.settings.rules.some(r => r.enabled && (r.type === RuleType.Tag || r.type === RuleType.Multi)); 1504 | if (hasEnabledTagRule && fileCache) { 1505 | const allTagsInFileWithHash = getAllTags(fileCache); 1506 | fileTags = allTagsInFileWithHash ? allTagsInFileWithHash.map(tag => tag.substring(1)) : []; 1507 | } 1508 | 1509 | for (const [index, currentRule] of this.settings.rules.entries()) { 1510 | if (!currentRule.enabled) { 1511 | continue; // Skip disabled rules 1512 | } 1513 | 1514 | let isMatch = false; 1515 | 1516 | // --- Match by Folder --- 1517 | if (currentRule.type === RuleType.Folder) { 1518 | isMatch = this._checkFolderMatch(file, currentRule); 1519 | } 1520 | // --- Match by Tag --- 1521 | else if (currentRule.type === RuleType.Tag) { 1522 | isMatch = this._checkTagMatch(fileTags, currentRule); 1523 | } 1524 | // --- Match by Property --- 1525 | else if (currentRule.type === RuleType.Property) { 1526 | isMatch = this._checkPropertyMatch(fileCache?.frontmatter, currentRule); 1527 | } 1528 | // --- Match by Multi --- 1529 | else if (currentRule.type === RuleType.Multi) { 1530 | if (currentRule.conditions && currentRule.conditions.length > 0) { 1531 | const checkCondition = (condition: SubCondition): boolean => { 1532 | let result = false; 1533 | if (condition.type === 'folder') { 1534 | result = this._checkFolderMatch(file, condition); 1535 | } else if (condition.type === 'tag') { 1536 | result = this._checkTagMatch(fileTags, condition); 1537 | } else if (condition.type === 'property') { 1538 | result = this._checkPropertyMatch(fileCache?.frontmatter, condition); 1539 | } 1540 | 1541 | // Apply negation if specified 1542 | return condition.negated ? !result : result; 1543 | }; 1544 | 1545 | if (currentRule.multiConditionLogic === 'all') { 1546 | // ALL (AND) logic: every condition must be true 1547 | isMatch = currentRule.conditions.every(checkCondition); 1548 | } else { 1549 | // ANY (OR) logic: at least one condition must be true 1550 | isMatch = currentRule.conditions.some(checkCondition); 1551 | } 1552 | } 1553 | } 1554 | // --- Match by Dataview Query --- 1555 | else if (currentRule.type === RuleType.Dataview) { 1556 | isMatch = await this._checkDataviewMatch(file, currentRule.dataviewQuery || ''); 1557 | } 1558 | 1559 | // Apply negation to the main rule if specified (for non-multi rules) 1560 | if (currentRule.type !== RuleType.Multi && currentRule.negated) { 1561 | isMatch = !isMatch; 1562 | } 1563 | 1564 | if (isMatch) { 1565 | const contentText = await this._fetchContentForRule(currentRule); 1566 | allApplicable.push({ rule: currentRule, contentText, index }); 1567 | } 1568 | } 1569 | return allApplicable; 1570 | } 1571 | 1572 | private _checkFolderMatch(file: TFile, rule: { path?: string, recursive?: boolean }): boolean { 1573 | if (rule.path === undefined) return false; 1574 | const ruleRecursive = rule.recursive === undefined ? true : rule.recursive; 1575 | 1576 | if (rule.path === "") { // Matches all files 1577 | return true; 1578 | } else if (rule.path === "/") { // Matches root folder 1579 | return ruleRecursive ? true : (file.parent?.isRoot() ?? false); 1580 | } else { 1581 | let normalizedRuleFolderPath = rule.path.endsWith('/') ? rule.path.slice(0, -1) : rule.path; 1582 | if (ruleRecursive) { 1583 | return file.path.startsWith(normalizedRuleFolderPath + '/'); 1584 | } else { 1585 | return file.parent?.path === normalizedRuleFolderPath; 1586 | } 1587 | } 1588 | } 1589 | 1590 | private _checkTagMatch(fileTags: string[] | null, rule: { tag?: string, includeSubtags?: boolean }): boolean { 1591 | if (!rule.tag || !fileTags) return false; 1592 | const ruleTag = rule.tag; 1593 | const includeSubtags = rule.includeSubtags ?? false; 1594 | for (const fileTag of fileTags) { 1595 | if (includeSubtags) { 1596 | if (fileTag === ruleTag || fileTag.startsWith(ruleTag + '/')) { 1597 | return true; 1598 | } 1599 | } else { 1600 | if (fileTag === ruleTag) { 1601 | return true; 1602 | } 1603 | } 1604 | } 1605 | return false; 1606 | } 1607 | 1608 | private _checkPropertyMatch(frontmatter: any, rule: { propertyName?: string, propertyValue?: string }): boolean { 1609 | if (!rule.propertyName || !frontmatter) return false; 1610 | const propertyKey = rule.propertyName; 1611 | const expectedPropertyValue = rule.propertyValue; 1612 | const actualPropertyValue = frontmatter[propertyKey]; 1613 | 1614 | // If the property exists in frontmatter 1615 | if (actualPropertyValue !== undefined && actualPropertyValue !== null) { 1616 | // If no expected value is specified, match any file that has this property 1617 | if (!expectedPropertyValue || expectedPropertyValue.trim() === '') { 1618 | return true; 1619 | } 1620 | 1621 | // Otherwise, check for exact value match 1622 | if (typeof actualPropertyValue === 'string') { 1623 | return actualPropertyValue === expectedPropertyValue; 1624 | } else if (Array.isArray(actualPropertyValue)) { 1625 | // For arrays, check if the expected value is one of the items 1626 | return actualPropertyValue.map(String).includes(expectedPropertyValue); 1627 | } else if (typeof actualPropertyValue === 'number' || typeof actualPropertyValue === 'boolean') { 1628 | return String(actualPropertyValue) === expectedPropertyValue; 1629 | } 1630 | } 1631 | return false; 1632 | } 1633 | 1634 | /** 1635 | * Checks if a file matches a Dataview query rule 1636 | * @param file The file to check against the dataview query 1637 | * @param query The dataview query string 1638 | * @returns True if the file matches the dataview query, false otherwise 1639 | */ 1640 | private async _checkDataviewMatch(file: TFile, query: string): Promise { 1641 | // Check if dataview plugin exists 1642 | // @ts-ignore - Access plugins using bracket notation 1643 | const dataviewPlugin = this.app.plugins.plugins?.dataview; 1644 | if (!dataviewPlugin) { 1645 | console.warn("VirtualFooter: Dataview plugin is required for dataview rules but is not installed or enabled."); 1646 | return false; 1647 | } 1648 | 1649 | try { 1650 | const dataviewApi = dataviewPlugin.api; 1651 | if (!dataviewApi) { 1652 | console.warn("VirtualFooter: Cannot access Dataview API."); 1653 | return false; 1654 | } 1655 | 1656 | // Execute the query against the active file 1657 | const results = await dataviewApi.query(query); 1658 | 1659 | // Dataview API returns a Success object with a 'successful' flag and 'value' property 1660 | if (!results || !results.successful || !results.value || !Array.isArray(results.value.values)) { 1661 | // If the query did not return valid results, log and return false 1662 | console.warn(`VirtualFooter: Dataview query did not return valid results for query: ${query} in file: ${file.path} Dataview error:`, results); 1663 | return false; 1664 | } 1665 | 1666 | // Extract file paths from the results 1667 | const resultPaths: string[] = []; 1668 | for (const page of results.value.values) { 1669 | if (page.path) { 1670 | resultPaths.push(page.path); 1671 | } 1672 | } 1673 | 1674 | // Check if current file path is in the results 1675 | return resultPaths.includes(file.path); 1676 | } catch (error) { 1677 | console.error(`VirtualFooter: Error executing Dataview query: ${query}`, error); 1678 | return false; 1679 | } 1680 | } 1681 | 1682 | /** 1683 | * Fetches the content for a given rule, either from direct text or from a specified file. 1684 | * @param rule The rule for which to fetch content. 1685 | * @returns A promise that resolves to the content string. 1686 | */ 1687 | private async _fetchContentForRule(rule: Rule): Promise { 1688 | if (rule.contentSource === ContentSource.File && rule.footerFilePath) { 1689 | const file = this.app.vault.getAbstractFileByPath(rule.footerFilePath); 1690 | if (file instanceof TFile) { 1691 | try { 1692 | return await this.app.vault.cachedRead(file); 1693 | } catch (error) { 1694 | console.error(`VirtualFooter: Error reading content file ${rule.footerFilePath}`, error); 1695 | return ``; // Return error message in content 1696 | } 1697 | } else { 1698 | console.warn(`VirtualFooter: Content file not found for rule: ${rule.footerFilePath}`); 1699 | return ``; // Return warning in content 1700 | } 1701 | } 1702 | return rule.footerText || ""; // Use direct text or empty string if not file 1703 | } 1704 | 1705 | /** 1706 | * Attaches event handlers to the injected content for internal link navigation. 1707 | * @param container The HTMLElement containing the rendered Markdown. 1708 | * @param sourcePath The path of the file where the content is injected, for link resolution. 1709 | * @param component The Obsidian Component associated with this content, for event registration. 1710 | */ 1711 | public attachInternalLinkHandlers(container: HTMLElement, sourcePath: string, component: Component): void { 1712 | // Handle left-click on internal links 1713 | component.registerDomEvent(container, 'click', (event: MouseEvent) => { 1714 | if (event.button !== 0) return; // Only handle left-clicks 1715 | const target = event.target as HTMLElement; 1716 | const linkElement = target.closest('a.internal-link') as HTMLAnchorElement; 1717 | if (linkElement) { 1718 | event.preventDefault(); // Prevent default link navigation 1719 | const href = linkElement.dataset.href; 1720 | if (href) { 1721 | const inNewPane = event.ctrlKey || event.metaKey; // Open in new pane if Ctrl/Cmd is pressed 1722 | this.app.workspace.openLinkText(href, sourcePath, inNewPane); 1723 | } 1724 | } 1725 | }); 1726 | 1727 | // Handle middle-click (auxclick) on internal links to open in a new pane 1728 | component.registerDomEvent(container, 'auxclick', (event: MouseEvent) => { 1729 | if (event.button !== 1) return; // Only handle middle-clicks 1730 | const target = event.target as HTMLElement; 1731 | const linkElement = target.closest('a.internal-link') as HTMLAnchorElement; 1732 | if (linkElement) { 1733 | event.preventDefault(); 1734 | const href = linkElement.dataset.href; 1735 | if (href) { 1736 | this.app.workspace.openLinkText(href, sourcePath, true); // Always open in new pane for middle-click 1737 | } 1738 | } 1739 | }); 1740 | } 1741 | 1742 | /** 1743 | * Loads plugin settings from storage, migrating old formats if necessary. 1744 | */ 1745 | async loadSettings() { 1746 | const loadedData = await this.loadData(); 1747 | // Start with a deep copy of default settings to ensure all fields are present 1748 | this.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); 1749 | 1750 | if (loadedData) { 1751 | // Handle potential old global renderLocation setting for migration 1752 | const oldGlobalRenderLocation = loadedData.renderLocation as RenderLocation | undefined; 1753 | 1754 | if (loadedData.rules && Array.isArray(loadedData.rules)) { 1755 | this.settings.rules = loadedData.rules.map((loadedRule: any) => 1756 | this._migrateRule(loadedRule, oldGlobalRenderLocation) 1757 | ); 1758 | } 1759 | // Load the new refreshOnFileOpen setting if it exists in loadedData 1760 | if (typeof loadedData.refreshOnFileOpen === 'boolean') { 1761 | this.settings.refreshOnFileOpen = loadedData.refreshOnFileOpen; 1762 | } 1763 | // Load the new renderInSourceMode setting if it exists 1764 | if (typeof loadedData.renderInSourceMode === 'boolean') { 1765 | this.settings.renderInSourceMode = loadedData.renderInSourceMode; 1766 | } 1767 | // Load the new refreshOnMetadataChange setting if it exists 1768 | if (typeof loadedData.refreshOnMetadataChange === 'boolean') { 1769 | this.settings.refreshOnMetadataChange = loadedData.refreshOnMetadataChange; 1770 | } 1771 | } 1772 | 1773 | // Ensure there's at least one rule, and all rules are normalized 1774 | if (!this.settings.rules || this.settings.rules.length === 0) { 1775 | // If no rules exist, add a default one 1776 | this.settings.rules = [JSON.parse(JSON.stringify(DEFAULT_SETTINGS.rules[0]))]; 1777 | this.normalizeRule(this.settings.rules[0]); 1778 | } else { 1779 | // Normalize all existing rules 1780 | this.settings.rules.forEach(rule => this.normalizeRule(rule)); 1781 | } 1782 | // Ensure global settings are definitely booleans 1783 | if (typeof this.settings.refreshOnFileOpen !== 'boolean') { 1784 | this.settings.refreshOnFileOpen = DEFAULT_SETTINGS.refreshOnFileOpen!; 1785 | } 1786 | if (typeof this.settings.renderInSourceMode !== 'boolean') { 1787 | this.settings.renderInSourceMode = DEFAULT_SETTINGS.renderInSourceMode!; 1788 | } 1789 | if (typeof this.settings.refreshOnMetadataChange !== 'boolean') { 1790 | this.settings.refreshOnMetadataChange = DEFAULT_SETTINGS.refreshOnMetadataChange!; 1791 | } 1792 | } 1793 | 1794 | /** 1795 | * Migrates a rule from an older settings format to the current Rule interface. 1796 | * @param loadedRule The rule object loaded from storage. 1797 | * @param globalRenderLocation An optional global render location from a very old settings format. 1798 | * @returns A migrated and normalized Rule object. 1799 | */ 1800 | private _migrateRule(loadedRule: any, globalRenderLocation?: RenderLocation): Rule { 1801 | // Determine rule type, defaulting if ambiguous 1802 | let type: RuleType; 1803 | if (Object.values(RuleType).includes(loadedRule.type as RuleType)) { 1804 | type = loadedRule.type as RuleType; 1805 | } else if (typeof loadedRule.folderPath === 'string') { // Legacy field 1806 | type = RuleType.Folder; 1807 | } else { 1808 | type = DEFAULT_SETTINGS.rules[0].type; 1809 | } 1810 | 1811 | // Determine content source, defaulting if ambiguous 1812 | let contentSource: ContentSource; 1813 | if (Object.values(ContentSource).includes(loadedRule.contentSource as ContentSource)) { 1814 | contentSource = loadedRule.contentSource as ContentSource; 1815 | } else { 1816 | // If folderPath existed (legacy) and contentSource is undefined, it was likely Text 1817 | contentSource = (typeof loadedRule.folderPath === 'string' && loadedRule.contentSource === undefined) 1818 | ? ContentSource.Text 1819 | : DEFAULT_SETTINGS.rules[0].contentSource; 1820 | } 1821 | 1822 | const migratedRule: Rule = { 1823 | name: loadedRule.name || DEFAULT_SETTINGS.rules[0].name, 1824 | enabled: loadedRule.enabled !== undefined ? loadedRule.enabled : DEFAULT_SETTINGS.rules[0].enabled, 1825 | type: type, 1826 | contentSource: contentSource, 1827 | footerText: loadedRule.footerText || '', // Retain name for compatibility 1828 | renderLocation: loadedRule.renderLocation || globalRenderLocation || DEFAULT_SETTINGS.rules[0].renderLocation, 1829 | recursive: loadedRule.recursive !== undefined ? loadedRule.recursive : true, 1830 | showInSeparateTab: loadedRule.showInSeparateTab || false, 1831 | sidebarTabName: loadedRule.sidebarTabName || '', 1832 | multiConditionLogic: loadedRule.multiConditionLogic || 'any', 1833 | renderAboveProperties: loadedRule.renderAboveProperties !== undefined ? loadedRule.renderAboveProperties : undefined, 1834 | renderAboveBacklinks: loadedRule.renderAboveBacklinks !== undefined ? loadedRule.renderAboveBacklinks : undefined, 1835 | dataviewQuery: loadedRule.dataviewQuery || '', 1836 | footerFilePath: loadedRule.footerFilePath || '', // Retained name for compatibility 1837 | showInPopover: loadedRule.showInPopover !== undefined ? loadedRule.showInPopover : true, 1838 | }; 1839 | 1840 | // Populate type-specific fields 1841 | if (migratedRule.type === RuleType.Folder) { 1842 | migratedRule.path = loadedRule.path !== undefined ? loadedRule.path : 1843 | (loadedRule.folderPath !== undefined ? loadedRule.folderPath : DEFAULT_SETTINGS.rules[0].path); 1844 | } else if (migratedRule.type === RuleType.Tag) { 1845 | migratedRule.tag = loadedRule.tag !== undefined ? loadedRule.tag : ''; 1846 | migratedRule.includeSubtags = loadedRule.includeSubtags !== undefined ? loadedRule.includeSubtags : false; 1847 | } else if (migratedRule.type === RuleType.Property) { 1848 | migratedRule.propertyName = loadedRule.propertyName || ''; 1849 | migratedRule.propertyValue = loadedRule.propertyValue || ''; 1850 | } else if (migratedRule.type === RuleType.Multi) { 1851 | migratedRule.conditions = loadedRule.conditions || []; 1852 | } 1853 | 1854 | // Populate content source-specific fields 1855 | if (migratedRule.contentSource === ContentSource.File) { 1856 | migratedRule.footerFilePath = loadedRule.footerFilePath || ''; // Retained name for compatibility 1857 | } 1858 | return migratedRule; // Normalization will happen after migration 1859 | } 1860 | 1861 | /** 1862 | * Normalizes a rule object, ensuring all required fields are present and defaults are applied. 1863 | * Also cleans up fields that are not relevant to the rule's current type or content source. 1864 | * @param rule The rule to normalize. 1865 | */ 1866 | public normalizeRule(rule: Rule): void { 1867 | // Create a copy of the rule to preserve original values during cleanup 1868 | const originalRule = { ...rule }; 1869 | 1870 | // Ensure basic fields have default values 1871 | rule.name = rule.name === undefined ? DEFAULT_SETTINGS.rules[0].name : rule.name; 1872 | rule.enabled = typeof rule.enabled === 'boolean' ? rule.enabled : DEFAULT_SETTINGS.rules[0].enabled!; 1873 | rule.type = rule.type || DEFAULT_SETTINGS.rules[0].type; 1874 | 1875 | // Clean up all type-specific fields before re-populating 1876 | delete rule.path; 1877 | delete rule.recursive; 1878 | delete rule.tag; 1879 | delete rule.includeSubtags; 1880 | delete rule.propertyName; 1881 | delete rule.propertyValue; 1882 | delete rule.conditions; 1883 | delete rule.multiConditionLogic; 1884 | delete rule.dataviewQuery; 1885 | 1886 | // Normalize based on RuleType, using values from the original rule if they exist 1887 | if (rule.type === RuleType.Folder) { 1888 | rule.path = originalRule.path === undefined ? (DEFAULT_SETTINGS.rules[0].path || '') : originalRule.path; 1889 | // 'recursive' is always true if path is "" (all files) 1890 | rule.recursive = rule.path === "" ? true : (typeof originalRule.recursive === 'boolean' ? originalRule.recursive : true); 1891 | } else if (rule.type === RuleType.Tag) { 1892 | rule.tag = originalRule.tag === undefined ? '' : originalRule.tag; 1893 | rule.includeSubtags = typeof originalRule.includeSubtags === 'boolean' ? originalRule.includeSubtags : false; 1894 | } else if (rule.type === RuleType.Property) { 1895 | rule.propertyName = originalRule.propertyName === undefined ? '' : originalRule.propertyName; 1896 | rule.propertyValue = originalRule.propertyValue === undefined ? '' : originalRule.propertyValue; 1897 | } else if (rule.type === RuleType.Multi) { 1898 | rule.conditions = Array.isArray(originalRule.conditions) ? originalRule.conditions : []; 1899 | rule.multiConditionLogic = originalRule.multiConditionLogic === 'all' ? 'all' : 'any'; 1900 | } else if (rule.type === RuleType.Dataview) { 1901 | rule.dataviewQuery = originalRule.dataviewQuery === undefined ? '' : originalRule.dataviewQuery; 1902 | } 1903 | 1904 | // Normalize content source and related fields 1905 | rule.contentSource = originalRule.contentSource || DEFAULT_SETTINGS.rules[0].contentSource; 1906 | rule.footerText = originalRule.footerText || ''; // Retain name for compatibility 1907 | rule.renderLocation = originalRule.renderLocation || DEFAULT_SETTINGS.rules[0].renderLocation; 1908 | 1909 | if (rule.contentSource === ContentSource.File) { 1910 | rule.footerFilePath = originalRule.footerFilePath || ''; // Retain name for compatibility 1911 | } else { // ContentSource.Text 1912 | delete rule.footerFilePath; 1913 | } 1914 | 1915 | // Normalize sidebar-specific fields 1916 | if (rule.renderLocation === RenderLocation.Sidebar) { 1917 | rule.showInSeparateTab = typeof originalRule.showInSeparateTab === 'boolean' ? originalRule.showInSeparateTab : false; 1918 | rule.sidebarTabName = originalRule.sidebarTabName || ''; 1919 | } else { 1920 | delete rule.showInSeparateTab; 1921 | delete rule.sidebarTabName; 1922 | } 1923 | 1924 | // Normalize positioning fields based on render location 1925 | if (rule.renderLocation === RenderLocation.Header) { 1926 | rule.renderAboveProperties = typeof originalRule.renderAboveProperties === 'boolean' ? originalRule.renderAboveProperties : false; 1927 | delete rule.renderAboveBacklinks; 1928 | } else if (rule.renderLocation === RenderLocation.Footer) { 1929 | rule.renderAboveBacklinks = typeof originalRule.renderAboveBacklinks === 'boolean' ? originalRule.renderAboveBacklinks : false; 1930 | delete rule.renderAboveProperties; 1931 | } else { 1932 | delete rule.renderAboveProperties; 1933 | delete rule.renderAboveBacklinks; 1934 | } 1935 | 1936 | // Normalize popover visibility setting 1937 | rule.showInPopover = typeof originalRule.showInPopover === 'boolean' ? originalRule.showInPopover : true; 1938 | } 1939 | 1940 | /** 1941 | * Saves the current plugin settings to storage and triggers a view refresh. 1942 | */ 1943 | async saveSettings() { 1944 | // Ensure all rules are normalized before saving 1945 | this.settings.rules.forEach(rule => this.normalizeRule(rule)); 1946 | await this.saveData(this.settings); 1947 | this.registerDynamicViews(); // Re-register views in case names/rules changed 1948 | this.handleActiveViewChange(); // Refresh views to apply changes 1949 | } 1950 | 1951 | async activateView(viewId: string) { 1952 | this.app.workspace.detachLeavesOfType(viewId); 1953 | 1954 | const leaf = this.app.workspace.getRightLeaf(true); 1955 | if (leaf) { 1956 | await leaf.setViewState({ 1957 | type: viewId, 1958 | active: true, 1959 | }); 1960 | 1961 | this.app.workspace.revealLeaf(leaf); 1962 | } 1963 | } 1964 | 1965 | private activateAllSidebarViews() { 1966 | this.activateView(VIRTUAL_CONTENT_VIEW_TYPE); 1967 | this.settings.rules.forEach((rule, index) => { 1968 | if (rule.enabled && rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) { 1969 | this.activateView(this.getSeparateViewId(index)); 1970 | } 1971 | }); 1972 | } 1973 | 1974 | private updateAllSidebarViews() { 1975 | const leaves = this.app.workspace.getLeavesOfType(VIRTUAL_CONTENT_VIEW_TYPE); 1976 | for (const leaf of leaves) { 1977 | if (leaf.view instanceof VirtualContentView) { 1978 | leaf.view.update(); 1979 | } 1980 | } 1981 | this.settings.rules.forEach((rule, index) => { 1982 | if (rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) { 1983 | const viewId = this.getSeparateViewId(index); 1984 | const separateLeaves = this.app.workspace.getLeavesOfType(viewId); 1985 | for (const leaf of separateLeaves) { 1986 | if (leaf.view instanceof VirtualContentView) { 1987 | leaf.view.update(); 1988 | } 1989 | } 1990 | } 1991 | }); 1992 | } 1993 | 1994 | public getLastSidebarContent(): { content: string, sourcePath: string } | null { 1995 | return this.lastSidebarContent; 1996 | } 1997 | 1998 | public getSeparateTabContent(viewId: string): { content: string, sourcePath: string } | null { 1999 | return this.lastSeparateTabContents.get(viewId) || null; 2000 | } 2001 | 2002 | private getSeparateViewId(ruleIndex: number): string { 2003 | return `${VIRTUAL_CONTENT_SEPARATE_VIEW_TYPE_PREFIX}${ruleIndex}`; 2004 | } 2005 | 2006 | private registerDynamicViews() { 2007 | this.settings.rules.forEach((rule, index) => { 2008 | if (rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) { 2009 | const viewId = this.getSeparateViewId(index); 2010 | const tabName = rule.sidebarTabName?.trim() ? `Virtual Content: ${rule.sidebarTabName}` : `Virtual Content: Rule ${index + 1}`; 2011 | this.registerView( 2012 | viewId, 2013 | (leaf) => new VirtualContentView(leaf, this, viewId, tabName, () => this.getSeparateTabContent(viewId)) 2014 | ); 2015 | } 2016 | }); 2017 | } 2018 | } 2019 | 2020 | // --- Settings Tab Class --- 2021 | 2022 | /** 2023 | * Manages the settings tab UI for the VirtualFooter plugin. 2024 | * Allows users to configure rules for dynamic content injection. 2025 | */ 2026 | class VirtualFooterSettingTab extends PluginSettingTab { 2027 | // Caches for suggestion lists to improve performance 2028 | private allFolderPathsCache: Set | null = null; 2029 | private allTagsCache: Set | null = null; 2030 | private allMarkdownFilePathsCache: Set | null = null; 2031 | private allPropertyNamesCache: Set | null = null; 2032 | private ruleExpandedStates: boolean[] = []; 2033 | private debouncedSave: () => void; 2034 | private debouncedSaveAndRefresh: () => void; 2035 | 2036 | 2037 | constructor(app: App, private plugin: VirtualFooterPlugin) { 2038 | super(app, plugin); 2039 | this.debouncedSave = debounce(() => this.plugin.saveSettings(), 1000, true); 2040 | this.debouncedSaveAndRefresh = debounce(() => { 2041 | this.plugin.saveSettings().then(() => this.display()); 2042 | }, 1000, true); 2043 | } 2044 | 2045 | /** 2046 | * Lazily gets and caches all unique folder paths in the vault. 2047 | * Includes special paths "" (all files) and "/" (root). 2048 | * @returns A set of available folder paths. 2049 | */ 2050 | private getAvailableFolderPaths(): Set { 2051 | if (this.allFolderPathsCache) return this.allFolderPathsCache; 2052 | 2053 | const paths = new Set(['/', '']); // Special paths 2054 | this.app.vault.getAllLoadedFiles().forEach(file => { 2055 | if (file.parent) { // Has a parent folder 2056 | const parentPath = file.parent.isRoot() ? '/' : (file.parent.path.endsWith('/') ? file.parent.path : file.parent.path + '/'); 2057 | if (parentPath !== '/') paths.add(parentPath); // Add parent path, ensuring trailing slash 2058 | } 2059 | // If the file itself is a folder (Obsidian's TFolder) 2060 | if ('children' in file && file.path !== '/') { // 'children' indicates a TFolder 2061 | const folderPath = file.path.endsWith('/') ? file.path : file.path + '/'; 2062 | paths.add(folderPath); // Add folder path, ensuring trailing slash 2063 | } 2064 | }); 2065 | this.allFolderPathsCache = paths; 2066 | return paths; 2067 | } 2068 | 2069 | /** 2070 | * Lazily gets and caches all unique tags (without '#') present in Markdown files. 2071 | * @returns A set of available tags. 2072 | */ 2073 | private getAvailableTags(): Set { 2074 | if (this.allTagsCache) return this.allTagsCache; 2075 | 2076 | const collectedTags = new Set(); 2077 | this.app.vault.getMarkdownFiles().forEach(file => { 2078 | const fileCache = this.app.metadataCache.getFileCache(file); 2079 | if (fileCache) { 2080 | const tagsInFile = getAllTags(fileCache); // Returns tags with '#' 2081 | tagsInFile?.forEach(tag => { 2082 | collectedTags.add(tag.substring(1)); // Store without '#' 2083 | }); 2084 | } 2085 | }); 2086 | this.allTagsCache = collectedTags; 2087 | return collectedTags; 2088 | } 2089 | 2090 | /** 2091 | * Lazily gets and caches all Markdown file paths in the vault. 2092 | * @returns A set of available Markdown file paths. 2093 | */ 2094 | private getAvailableMarkdownFilePaths(): Set { 2095 | if (this.allMarkdownFilePathsCache) return this.allMarkdownFilePathsCache; 2096 | 2097 | const paths = new Set(); 2098 | this.app.vault.getMarkdownFiles().forEach(file => { 2099 | paths.add(file.path); 2100 | }); 2101 | this.allMarkdownFilePathsCache = paths; 2102 | return paths; 2103 | } 2104 | 2105 | /** 2106 | * Lazily gets and caches all unique frontmatter property keys from Markdown files. 2107 | * @returns A set of available property names. 2108 | */ 2109 | private getAvailablePropertyNames(): Set { 2110 | if (this.allPropertyNamesCache) return this.allPropertyNamesCache; 2111 | 2112 | // @ts-ignore - getFrontmatterPropertyKeys is an undocumented API, but widely used. 2113 | const keys = this.app.metadataCache.getFrontmatterPropertyKeys?.() || []; 2114 | this.allPropertyNamesCache = new Set(keys); 2115 | return this.allPropertyNamesCache; 2116 | } 2117 | 2118 | /** 2119 | * Renders the settings tab UI. 2120 | * This method is called by Obsidian when the settings tab is opened. 2121 | */ 2122 | display(): void { 2123 | const { containerEl } = this; 2124 | containerEl.empty(); // Clear previous content 2125 | 2126 | // --- Plugin Header --- 2127 | containerEl.createEl('h2', { text: 'Virtual Content Settings' }); 2128 | containerEl.createEl('p', { text: 'Define rules to dynamically add content to the header or footer of notes based on their folder, tags, or properties.' }); 2129 | 2130 | // --- General Settings Section --- 2131 | new Setting(containerEl) 2132 | .setName('Render in source mode') 2133 | .setDesc('If enabled, virtual content will be rendered in source mode. By default, content only appears in Live Preview and Reading modes.') 2134 | .addToggle(toggle => toggle 2135 | .setValue(this.plugin.settings.renderInSourceMode!) 2136 | .onChange(async (value) => { 2137 | this.plugin.settings.renderInSourceMode = value; 2138 | await this.plugin.saveSettings(); 2139 | })); 2140 | 2141 | new Setting(containerEl) 2142 | .setName('Refresh on focus change') 2143 | .setDesc('If enabled, virtual content will refresh when switching files. This may cause a slight flicker but is useful if you frequently change the text of virtual content and need immediate updates. If disabled the virtual content will be updated on file open and view change (editing/reading view). To prevent virtual content in the sidebar disappearing when clicking out of a note, it will always keep the last notes virtual content open, which means new tabs will show the virtual content of the last used note. Disabled by default.') 2144 | .addToggle(toggle => toggle 2145 | .setValue(this.plugin.settings.refreshOnFileOpen!) // Value is ensured by loadSettings 2146 | .onChange(async (value) => { 2147 | this.plugin.settings.refreshOnFileOpen = value; 2148 | await this.plugin.saveSettings(); 2149 | })); 2150 | 2151 | new Setting(containerEl) 2152 | .setName('Refresh on metadata change') 2153 | .setDesc('If enabled, virtual content will refresh when the current note\'s metadata (frontmatter, tags) changes. This is useful for rules that depend on properties or tags and need to update immediately when those values change.') 2154 | .addToggle(toggle => toggle 2155 | .setValue(this.plugin.settings.refreshOnMetadataChange!) 2156 | .onChange(async (value) => { 2157 | this.plugin.settings.refreshOnMetadataChange = value; 2158 | await this.plugin.saveSettings(); 2159 | })); 2160 | 2161 | containerEl.createEl('h3', { text: 'Rules' }); 2162 | 2163 | 2164 | // Invalidate caches to ensure fresh suggestions each time the tab is displayed 2165 | this.allFolderPathsCache = null; 2166 | this.allTagsCache = null; 2167 | this.allMarkdownFilePathsCache = null; 2168 | this.allPropertyNamesCache = null; 2169 | 2170 | // Synchronize ruleExpandedStates with the current number of rules 2171 | const numRules = this.plugin.settings.rules.length; 2172 | while (this.ruleExpandedStates.length < numRules) { 2173 | this.ruleExpandedStates.push(false); // Default new rules to collapsed 2174 | } 2175 | if (this.ruleExpandedStates.length > numRules) { 2176 | this.ruleExpandedStates.length = numRules; // Truncate if rules were removed 2177 | } 2178 | 2179 | 2180 | const rulesContainer = containerEl.createDiv('rules-container virtual-footer-rules-container'); 2181 | 2182 | // Ensure settings.rules array exists and has at least one rule 2183 | if (!this.plugin.settings.rules) { 2184 | this.plugin.settings.rules = []; 2185 | } 2186 | if (this.plugin.settings.rules.length === 0) { 2187 | const newRule = JSON.parse(JSON.stringify(DEFAULT_SETTINGS.rules[0])); 2188 | this.plugin.normalizeRule(newRule); // Normalize the new default rule 2189 | this.plugin.settings.rules.push(newRule); 2190 | // Ensure ruleExpandedStates is updated for the new rule 2191 | if (this.ruleExpandedStates.length === 0) { 2192 | this.ruleExpandedStates.push(false); 2193 | } 2194 | } 2195 | 2196 | // Render controls for each rule 2197 | this.plugin.settings.rules.forEach((rule, index) => { 2198 | this.renderRuleControls(rule, index, rulesContainer); 2199 | }); 2200 | 2201 | // --- Add New Rule Button --- 2202 | new Setting(containerEl) 2203 | .addButton(button => button 2204 | .setButtonText('Add new rule') 2205 | .setCta() // Call to action style 2206 | .setClass('virtual-footer-add-button') 2207 | .onClick(async () => { 2208 | const newRule = JSON.parse(JSON.stringify(DEFAULT_SETTINGS.rules[0])); 2209 | this.plugin.normalizeRule(newRule); 2210 | this.plugin.settings.rules.push(newRule); 2211 | this.ruleExpandedStates.push(true); // New rule is initially expanded 2212 | await this.plugin.saveSettings(); 2213 | this.display(); // Re-render to show the new rule and update indices 2214 | })); 2215 | } 2216 | 2217 | /** 2218 | * Renders the UI controls for a single rule within the settings tab. 2219 | * @param rule The rule object to render controls for. 2220 | * @param index The index of the rule in the settings array. 2221 | * @param containerEl The parent HTMLElement to append the rule controls to. 2222 | */ 2223 | private renderRuleControls(rule: Rule, index: number, containerEl: HTMLElement): void { 2224 | const ruleDiv = containerEl.createDiv('rule-item virtual-footer-rule-item'); 2225 | 2226 | // Apply stored expansion state or default to collapsed 2227 | if (!this.ruleExpandedStates[index]) { 2228 | ruleDiv.addClass('is-collapsed'); 2229 | } 2230 | 2231 | const ruleNameDisplay = (rule.name && rule.name.trim() !== '') ? rule.name : 'Unnamed Rule'; 2232 | const ruleHeadingText = `Rule ${index + 1}: ${ruleNameDisplay}`; 2233 | const ruleHeading = ruleDiv.createEl('h4', { text: ruleHeadingText }); 2234 | ruleHeading.addClass('virtual-footer-rule-heading'); 2235 | 2236 | 2237 | const ruleContentContainer = ruleDiv.createDiv('virtual-footer-rule-content'); 2238 | 2239 | // Toggle collapse/expand on heading click and update state 2240 | ruleHeading.addEventListener('click', () => { 2241 | const isNowExpanded = !ruleDiv.classList.toggle('is-collapsed'); 2242 | this.ruleExpandedStates[index] = isNowExpanded; 2243 | }); 2244 | 2245 | // --- Rule Name Setting --- 2246 | new Setting(ruleContentContainer) 2247 | .setName('Rule name') 2248 | .setDesc('A descriptive name for this rule (e.g., "Project Notes Footer").') 2249 | .addText(text => text 2250 | .setPlaceholder('e.g., Blog Post Footer') 2251 | .setValue(rule.name || '') 2252 | .onChange((value) => { 2253 | rule.name = value; 2254 | // Update heading text dynamically 2255 | const newNameDisplay = (value && value.trim() !== '') ? value : 'Unnamed Rule'; 2256 | ruleHeading.textContent = `Rule ${index + 1}: ${newNameDisplay}`; 2257 | this.debouncedSave(); 2258 | })); 2259 | 2260 | // --- Enabled/Disabled Toggle --- 2261 | new Setting(ruleContentContainer) 2262 | .setName('Enabled') 2263 | .setDesc('If disabled, this rule will not be applied.') 2264 | .addToggle((toggle: any) => toggle 2265 | .setValue(rule.enabled!) // normalizeRule ensures 'enabled' is boolean 2266 | .onChange(async (value: boolean) => { 2267 | rule.enabled = value; 2268 | await this.plugin.saveSettings(); 2269 | })); 2270 | 2271 | // --- Rule Type Setting --- 2272 | new Setting(ruleContentContainer) 2273 | .setName('Rule type') 2274 | .setDesc('Apply this rule based on folder, tag, property, or a combination.') 2275 | .addDropdown(dropdown => dropdown 2276 | .addOption(RuleType.Folder, 'Folder') 2277 | .addOption(RuleType.Tag, 'Tag') 2278 | .addOption(RuleType.Property, 'Property') 2279 | .addOption(RuleType.Multi, 'Multi-condition') 2280 | .addOption(RuleType.Dataview, 'Dataview') 2281 | .setValue(rule.type) 2282 | .onChange(async (value: string) => { 2283 | rule.type = value as RuleType; 2284 | // When switching to Multi, we might want to convert the old rule 2285 | if (rule.type === RuleType.Multi) { 2286 | const oldRule = { ...rule }; 2287 | rule.conditions = []; 2288 | // This logic is complex, so for now we just start with a clean slate. 2289 | // A more advanced version could auto-convert the previous simple rule. 2290 | } 2291 | this.plugin.normalizeRule(rule); // Re-normalize for type-specific fields 2292 | await this.plugin.saveSettings(); 2293 | this.display(); // Re-render to show/hide type-specific settings 2294 | })); 2295 | 2296 | // --- Type-Specific Settings --- 2297 | if (rule.type === RuleType.Folder) { 2298 | new Setting(ruleContentContainer) 2299 | .setName('Condition') 2300 | .setDesc('Choose whether this condition should be met or not met.') 2301 | .addDropdown(dropdown => dropdown 2302 | .addOption('is', 'is') 2303 | .addOption('not', 'not') 2304 | .setValue(rule.negated ? 'not' : 'is') 2305 | .onChange(async (value: 'is' | 'not') => { 2306 | rule.negated = value === 'not'; 2307 | await this.plugin.saveSettings(); 2308 | }) 2309 | ); 2310 | 2311 | new Setting(ruleContentContainer) 2312 | .setName('Folder path') 2313 | .setDesc('Path for the rule. Use "" for all files, "/" for root folder, or "FolderName/" for specific folders (ensure trailing slash for non-root folders).') 2314 | .addText(text => { 2315 | text.setPlaceholder('e.g., Meetings/, /, or empty for all') 2316 | .setValue(rule.path || '') 2317 | .onChange((value) => { 2318 | rule.path = value; 2319 | this.plugin.normalizeRule(rule); // Normalize path and recursive flag 2320 | this.debouncedSaveAndRefresh(); 2321 | }); 2322 | // Attach suggestion provider for folder paths 2323 | new MultiSuggest(text.inputEl, this.getAvailableFolderPaths(), (selectedPath) => { 2324 | rule.path = selectedPath; 2325 | this.plugin.normalizeRule(rule); 2326 | text.setValue(selectedPath); // Update text field with selection 2327 | this.plugin.saveSettings().then(() => this.display()); 2328 | }, this.plugin.app); 2329 | }); 2330 | 2331 | new Setting(ruleContentContainer) 2332 | .setName('Include subfolders (recursive)') 2333 | .setDesc('If enabled, rule applies to files in subfolders. For "all files" (empty path), this is always true. For root path ("/"), enabling applies to all vault files, disabling applies only to files directly in the root.') 2334 | .addToggle(toggle => { 2335 | toggle.setValue(rule.recursive!) // normalizeRule ensures 'recursive' is boolean 2336 | .onChange(async (value) => { 2337 | rule.recursive = value; 2338 | await this.plugin.saveSettings(); 2339 | }); 2340 | // Disable toggle if path is "" (all files), as recursive is always true 2341 | if (rule.path === "") { 2342 | toggle.setDisabled(true); 2343 | } 2344 | }); 2345 | 2346 | } else if (rule.type === RuleType.Tag) { 2347 | new Setting(ruleContentContainer) 2348 | .setName('Condition') 2349 | .setDesc('Choose whether this condition should be met or not met.') 2350 | .addDropdown(dropdown => dropdown 2351 | .addOption('is', 'is') 2352 | .addOption('not', 'not') 2353 | .setValue(rule.negated ? 'not' : 'is') 2354 | .onChange(async (value: 'is' | 'not') => { 2355 | rule.negated = value === 'not'; 2356 | await this.plugin.saveSettings(); 2357 | }) 2358 | ); 2359 | 2360 | new Setting(ruleContentContainer) 2361 | .setName('Tag value') 2362 | .setDesc('Tag to match (without the # prefix). E.g., "project" or "status/done".') 2363 | .addText(text => { 2364 | text.setPlaceholder('e.g., important or project/alpha') 2365 | .setValue(rule.tag || '') 2366 | .onChange((value) => { 2367 | // Ensure tag doesn't start with '#' 2368 | rule.tag = value.startsWith('#') ? value.substring(1) : value; 2369 | this.debouncedSave(); 2370 | }); 2371 | new MultiSuggest(text.inputEl, this.getAvailableTags(), (selectedTag) => { 2372 | const normalizedTag = selectedTag.startsWith('#') ? selectedTag.substring(1) : selectedTag; 2373 | rule.tag = normalizedTag; 2374 | text.setValue(normalizedTag); 2375 | this.plugin.saveSettings(); 2376 | }, this.plugin.app); 2377 | }); 2378 | 2379 | new Setting(ruleContentContainer) 2380 | .setName('Include subtags') 2381 | .setDesc("If enabled, a rule for 'tag' will also apply to 'tag/subtag1', 'tag/subtag2/subtag3', etc. If disabled, it only applies to the exact tag.") 2382 | .addToggle(toggle => { 2383 | toggle.setValue(rule.includeSubtags!) // normalizeRule ensures 'includeSubtags' is boolean 2384 | .onChange(async (value) => { 2385 | rule.includeSubtags = value; 2386 | await this.plugin.saveSettings(); 2387 | }); 2388 | }); 2389 | } else if (rule.type === RuleType.Property) { 2390 | new Setting(ruleContentContainer) 2391 | .setName('Condition') 2392 | .setDesc('Choose whether this condition should be met or not met.') 2393 | .addDropdown(dropdown => dropdown 2394 | .addOption('is', 'is') 2395 | .addOption('not', 'not') 2396 | .setValue(rule.negated ? 'not' : 'is') 2397 | .onChange(async (value: 'is' | 'not') => { 2398 | rule.negated = value === 'not'; 2399 | await this.plugin.saveSettings(); 2400 | }) 2401 | ); 2402 | 2403 | new Setting(ruleContentContainer) 2404 | .setName('Property name') 2405 | .setDesc('The name of the Obsidian property (frontmatter key) to match.') 2406 | .addText(text => { 2407 | text.setPlaceholder('e.g., status, type, author') 2408 | .setValue(rule.propertyName || '') 2409 | .onChange((value) => { 2410 | rule.propertyName = value; 2411 | this.debouncedSave(); 2412 | }); 2413 | new MultiSuggest(text.inputEl, this.getAvailablePropertyNames(), (selectedName) => { 2414 | rule.propertyName = selectedName; 2415 | text.setValue(selectedName); 2416 | this.plugin.saveSettings(); 2417 | }, this.plugin.app); 2418 | }); 2419 | 2420 | new Setting(ruleContentContainer) 2421 | .setName('Property value') 2422 | .setDesc('The value the property should have. Leave empty to match any file that has this property (regardless of value). For list/array properties, matches if this value is one of the items.') 2423 | .addText(text => text 2424 | .setPlaceholder('e.g., complete, article, John Doe (or leave empty)') 2425 | .setValue(rule.propertyValue || '') 2426 | .onChange((value) => { 2427 | rule.propertyValue = value; 2428 | this.debouncedSave(); 2429 | })); 2430 | } else if (rule.type === RuleType.Multi) { 2431 | this.renderMultiConditionControls(rule, ruleContentContainer); 2432 | } else if (rule.type === RuleType.Dataview) { 2433 | new Setting(ruleContentContainer) 2434 | .setName('Condition') 2435 | .setDesc('Choose whether this condition should be met or not met.') 2436 | .addDropdown(dropdown => dropdown 2437 | .addOption('is', 'is') 2438 | .addOption('not', 'not') 2439 | .setValue(rule.negated ? 'not' : 'is') 2440 | .onChange(async (value: 'is' | 'not') => { 2441 | rule.negated = value === 'not'; 2442 | await this.plugin.saveSettings(); 2443 | }) 2444 | ); 2445 | 2446 | new Setting(ruleContentContainer) 2447 | .setName('Dataview query') 2448 | .setDesc('Enter a Dataview LIST query to match notes where this rule should apply.') 2449 | .addTextArea(text => text 2450 | .setPlaceholder('LIST FROM "References/Authors" WHERE startswith(file.name, "Test") OR startswith(file.name, "Example")') 2451 | .setValue(rule.dataviewQuery || '') 2452 | .onChange((value) => { 2453 | rule.dataviewQuery = value; 2454 | this.debouncedSave(); 2455 | })); 2456 | 2457 | const infoDiv = ruleContentContainer.createDiv('dataview-info'); 2458 | infoDiv.createEl('p', { 2459 | text: 'Note: The Dataview plugin must be installed for this rule type to work.', 2460 | cls: 'setting-item-description' 2461 | }); 2462 | } 2463 | 2464 | // --- Content Source Settings --- 2465 | new Setting(ruleContentContainer) 2466 | .setName('Content source') 2467 | .setDesc('Where to get the content from: direct text input or a separate Markdown file.') 2468 | .addDropdown(dropdown => dropdown 2469 | .addOption(ContentSource.Text, 'Direct text') 2470 | .addOption(ContentSource.File, 'Markdown file') 2471 | .setValue(rule.contentSource || ContentSource.Text) // Default to Text if undefined 2472 | .onChange(async (value: string) => { 2473 | rule.contentSource = value as ContentSource; 2474 | this.plugin.normalizeRule(rule); // Normalize for content source specific fields 2475 | await this.plugin.saveSettings(); 2476 | this.display(); // Re-render to show/hide content source specific fields 2477 | })); 2478 | 2479 | if (rule.contentSource === ContentSource.File) { 2480 | new Setting(ruleContentContainer) 2481 | .setName('Content file path') 2482 | .setDesc('Path to the .md file to use as content (e.g., "templates/common-footer.md").') 2483 | .addText(text => { 2484 | text.setPlaceholder('e.g., templates/common-footer.md') 2485 | .setValue(rule.footerFilePath || '') // Retained name for compatibility 2486 | .onChange((value) => { 2487 | rule.footerFilePath = value; 2488 | this.debouncedSave(); 2489 | }); 2490 | new MultiSuggest(text.inputEl, this.getAvailableMarkdownFilePaths(), (selectedPath) => { 2491 | rule.footerFilePath = selectedPath; 2492 | text.setValue(selectedPath); 2493 | this.plugin.saveSettings(); 2494 | }, this.plugin.app); 2495 | }); 2496 | } else { // ContentSource.Text 2497 | new Setting(ruleContentContainer) 2498 | .setName('Content text') 2499 | .setDesc('Markdown text to display. This will be rendered.') 2500 | .addTextArea(text => text 2501 | .setPlaceholder('Enter your markdown content here...\nSupports multiple lines and **Markdown** formatting.') 2502 | .setValue(rule.footerText || '') // Retained name for compatibility 2503 | .onChange((value) => { 2504 | rule.footerText = value; 2505 | this.debouncedSave(); 2506 | })); 2507 | } 2508 | 2509 | // --- Render Location Setting --- 2510 | new Setting(ruleContentContainer) 2511 | .setName('Render location') 2512 | .setDesc('Choose whether this rule renders its content in the header, footer, or a dedicated sidebar tab.') 2513 | .addDropdown(dropdown => dropdown 2514 | .addOption(RenderLocation.Footer, 'Footer') 2515 | .addOption(RenderLocation.Header, 'Header') 2516 | .addOption(RenderLocation.Sidebar, 'Sidebar') 2517 | .setValue(rule.renderLocation || RenderLocation.Footer) // Default to Footer 2518 | .onChange(async (value: string) => { 2519 | rule.renderLocation = value as RenderLocation; 2520 | this.plugin.normalizeRule(rule); 2521 | await this.plugin.saveSettings(); 2522 | this.display(); 2523 | })); 2524 | 2525 | // --- Sidebar-Specific Settings --- 2526 | if (rule.renderLocation === RenderLocation.Sidebar) { 2527 | new Setting(ruleContentContainer) 2528 | .setName('Show in separate tab') 2529 | .setDesc('If enabled, this content will appear in its own sidebar tab instead of being combined with other sidebar rules.') 2530 | .addToggle(toggle => toggle 2531 | .setValue(rule.showInSeparateTab!) 2532 | .onChange(async (value) => { 2533 | rule.showInSeparateTab = value; 2534 | await this.plugin.saveSettings(); 2535 | this.display(); // Re-render to show/hide tab name setting 2536 | })); 2537 | 2538 | if (rule.showInSeparateTab) { 2539 | new Setting(ruleContentContainer) 2540 | .setName('Sidebar tab name') 2541 | .setDesc('The name for the separate sidebar tab. If empty, a default name will be used.') 2542 | .addText(text => text 2543 | .setPlaceholder('e.g., Related Notes') 2544 | .setValue(rule.sidebarTabName || '') 2545 | .onChange((value) => { 2546 | rule.sidebarTabName = value; 2547 | this.debouncedSave(); 2548 | })); 2549 | } 2550 | } 2551 | 2552 | // --- Header-Specific Settings --- 2553 | if (rule.renderLocation === RenderLocation.Header) { 2554 | new Setting(ruleContentContainer) 2555 | .setName('Render above properties') 2556 | .setDesc('If enabled, header content will be rendered above the frontmatter properties section.') 2557 | .addToggle(toggle => toggle 2558 | .setValue(rule.renderAboveProperties || false) 2559 | .onChange(async (value) => { 2560 | rule.renderAboveProperties = value; 2561 | await this.plugin.saveSettings(); 2562 | })); 2563 | } 2564 | 2565 | // --- Footer-Specific Settings --- 2566 | if (rule.renderLocation === RenderLocation.Footer) { 2567 | new Setting(ruleContentContainer) 2568 | .setName('Render above backlinks') 2569 | .setDesc('If enabled, footer content will be rendered above the embedded backlinks section. It is recommended to only enable this if you have backlinks enabled in the note, otherwise the note height may be affected.') 2570 | .addToggle(toggle => toggle 2571 | .setValue(rule.renderAboveBacklinks || false) 2572 | .onChange(async (value) => { 2573 | rule.renderAboveBacklinks = value; 2574 | await this.plugin.saveSettings(); 2575 | })); 2576 | } 2577 | 2578 | // --- Popover Visibility Setting --- 2579 | new Setting(ruleContentContainer) 2580 | .setName('Show in popover views') 2581 | .setDesc('If enabled, this rule\'s content will be shown when viewing notes in hover popovers. If disabled, the content will be hidden in popover views.') 2582 | .addToggle(toggle => toggle 2583 | .setValue(rule.showInPopover !== undefined ? rule.showInPopover : true) 2584 | .onChange(async (value) => { 2585 | rule.showInPopover = value; 2586 | await this.plugin.saveSettings(); 2587 | })); 2588 | 2589 | // --- Rule Actions: Reorder and Delete --- 2590 | const ruleActionsSetting = new Setting(ruleContentContainer) 2591 | .setClass('virtual-footer-rule-actions'); 2592 | 2593 | // Move Up Button 2594 | ruleActionsSetting.addButton(button => button 2595 | .setIcon('arrow-up') 2596 | .setTooltip('Move rule up') 2597 | .setClass('virtual-footer-move-button') 2598 | .setDisabled(index === 0) 2599 | .onClick(async () => { 2600 | if (index > 0) { 2601 | const rules = this.plugin.settings.rules; 2602 | const ruleToMove = rules.splice(index, 1)[0]; 2603 | rules.splice(index - 1, 0, ruleToMove); 2604 | 2605 | const expandedStateToMove = this.ruleExpandedStates.splice(index, 1)[0]; 2606 | this.ruleExpandedStates.splice(index - 1, 0, expandedStateToMove); 2607 | 2608 | await this.plugin.saveSettings(); 2609 | this.display(); 2610 | } 2611 | })); 2612 | 2613 | // Move Down Button 2614 | ruleActionsSetting.addButton(button => button 2615 | .setIcon('arrow-down') 2616 | .setTooltip('Move rule down') 2617 | .setClass('virtual-footer-move-button') 2618 | .setDisabled(index === this.plugin.settings.rules.length - 1) 2619 | .onClick(async () => { 2620 | if (index < this.plugin.settings.rules.length - 1) { 2621 | const rules = this.plugin.settings.rules; 2622 | const ruleToMove = rules.splice(index, 1)[0]; 2623 | rules.splice(index + 1, 0, ruleToMove); 2624 | 2625 | const expandedStateToMove = this.ruleExpandedStates.splice(index, 1)[0]; 2626 | this.ruleExpandedStates.splice(index + 1, 0, expandedStateToMove); 2627 | 2628 | await this.plugin.saveSettings(); 2629 | this.display(); 2630 | } 2631 | })); 2632 | 2633 | // Spacer to push delete button to the right 2634 | ruleActionsSetting.controlEl.createDiv({ cls: 'virtual-footer-actions-spacer' }); 2635 | 2636 | 2637 | // Delete Rule Button 2638 | ruleActionsSetting.addButton(button => button 2639 | .setButtonText('Delete rule') 2640 | .setWarning() // Style as a warning/destructive action 2641 | .setClass('virtual-footer-delete-button') 2642 | .onClick(async () => { 2643 | // Confirmation could be added here if desired 2644 | this.plugin.settings.rules.splice(index, 1); // Remove rule from array 2645 | this.ruleExpandedStates.splice(index, 1); // Remove corresponding state 2646 | await this.plugin.saveSettings(); 2647 | this.display(); // Re-render to reflect deletion and update indices 2648 | })); 2649 | } 2650 | 2651 | private renderMultiConditionControls(rule: Rule, containerEl: HTMLElement): void { 2652 | new Setting(containerEl) 2653 | .setName('Condition logic') 2654 | .setDesc('Choose whether any condition or all conditions must be met.') 2655 | .addDropdown(dropdown => dropdown 2656 | .addOption('any', 'Any condition') 2657 | .addOption('all', 'All conditions') 2658 | .setValue(rule.multiConditionLogic || 'any') 2659 | .onChange(async (value: 'any' | 'all') => { 2660 | rule.multiConditionLogic = value; 2661 | await this.plugin.saveSettings(); 2662 | })); 2663 | 2664 | const conditionsSetting = new Setting(containerEl) 2665 | .setName('Conditions') 2666 | .setDesc('This rule will apply if the selected logic is met by the following conditions.'); 2667 | 2668 | // Add the hint as a separate paragraph below the description 2669 | const descEl = conditionsSetting.settingEl.querySelector('.setting-item-description'); 2670 | if (descEl) { 2671 | const hintEl = document.createElement('p'); 2672 | hintEl.className = 'setting-item-description'; 2673 | hintEl.innerText = 'Hint: For very complex rules, consider using the Dataview rule type instead.'; 2674 | descEl.insertAdjacentElement('afterend', hintEl); 2675 | } 2676 | 2677 | const conditionsContainer = containerEl.createDiv('virtual-footer-conditions-container'); 2678 | rule.conditions?.forEach((condition, index) => { 2679 | this.renderSubConditionControls(condition, index, rule, conditionsContainer); 2680 | }); 2681 | 2682 | new Setting(containerEl) 2683 | .addButton(button => button 2684 | .setButtonText('Add condition') 2685 | .setCta() 2686 | .onClick(async () => { 2687 | rule.conditions = rule.conditions || []; 2688 | rule.conditions.push({ type: 'folder', path: '', recursive: true, negated: false }); 2689 | await this.plugin.saveSettings(); 2690 | this.display(); 2691 | })); 2692 | } 2693 | 2694 | private renderSubConditionControls(condition: SubCondition, index: number, rule: Rule, containerEl: HTMLElement): void { 2695 | const conditionDiv = containerEl.createDiv('virtual-footer-sub-condition-item'); 2696 | 2697 | const setting = new Setting(conditionDiv) 2698 | .addDropdown(dropdown => dropdown 2699 | .addOption('is', 'is') 2700 | .addOption('not', 'not') 2701 | .setValue(condition.negated ? 'not' : 'is') 2702 | .onChange(async (value: 'is' | 'not') => { 2703 | condition.negated = value === 'not'; 2704 | await this.plugin.saveSettings(); 2705 | }) 2706 | ) 2707 | .addDropdown(dropdown => dropdown 2708 | .addOption('folder', 'Folder') 2709 | .addOption('tag', 'Tag') 2710 | .addOption('property', 'Property') 2711 | .setValue(condition.type) 2712 | .onChange(async (value: 'folder' | 'tag' | 'property') => { 2713 | condition.type = value; 2714 | // Reset fields when type changes 2715 | delete condition.path; 2716 | delete condition.recursive; 2717 | delete condition.tag; 2718 | delete condition.includeSubtags; 2719 | delete condition.propertyName; 2720 | delete condition.propertyValue; 2721 | await this.plugin.saveSettings(); 2722 | this.display(); 2723 | }) 2724 | ); 2725 | 2726 | if (condition.type === 'folder') { 2727 | setting.addText(text => { 2728 | text.setPlaceholder('Folder path') 2729 | .setValue(condition.path || '') 2730 | .onChange((value) => { 2731 | condition.path = value; 2732 | this.debouncedSave(); 2733 | }); 2734 | new MultiSuggest(text.inputEl, this.getAvailableFolderPaths(), (selected) => { 2735 | condition.path = selected; 2736 | text.setValue(selected); 2737 | this.plugin.saveSettings(); 2738 | }, this.plugin.app); 2739 | }); 2740 | setting.addToggle(toggle => toggle 2741 | .setTooltip('Include subfolders') 2742 | .setValue(condition.recursive ?? true) 2743 | .onChange(async (value) => { 2744 | condition.recursive = value; 2745 | await this.plugin.saveSettings(); 2746 | }) 2747 | ); 2748 | } else if (condition.type === 'tag') { 2749 | setting.addText(text => { 2750 | text.setPlaceholder('Tag value (no #)') 2751 | .setValue(condition.tag || '') 2752 | .onChange((value) => { 2753 | condition.tag = value.startsWith('#') ? value.substring(1) : value; 2754 | this.debouncedSave(); 2755 | }); 2756 | new MultiSuggest(text.inputEl, this.getAvailableTags(), (selected) => { 2757 | const normalized = selected.startsWith('#') ? selected.substring(1) : selected; 2758 | condition.tag = normalized; 2759 | text.setValue(normalized); 2760 | this.plugin.saveSettings(); 2761 | }, this.plugin.app); 2762 | }); 2763 | setting.addToggle(toggle => toggle 2764 | .setTooltip('Include subtags') 2765 | .setValue(condition.includeSubtags ?? false) 2766 | .onChange(async (value) => { 2767 | condition.includeSubtags = value; 2768 | await this.plugin.saveSettings(); 2769 | }) 2770 | ); 2771 | } else if (condition.type === 'property') { 2772 | setting.addText(text => { 2773 | text.setPlaceholder('Property name') 2774 | .setValue(condition.propertyName || '') 2775 | .onChange((value) => { 2776 | condition.propertyName = value; 2777 | this.debouncedSave(); 2778 | }); 2779 | new MultiSuggest(text.inputEl, this.getAvailablePropertyNames(), (selected) => { 2780 | condition.propertyName = selected; 2781 | text.setValue(selected); 2782 | this.plugin.saveSettings(); 2783 | }, this.plugin.app); 2784 | }); 2785 | setting.addText(text => text 2786 | .setPlaceholder('Property value (or leave empty)') 2787 | .setValue(condition.propertyValue || '') 2788 | .onChange((value) => { 2789 | condition.propertyValue = value; 2790 | this.debouncedSave(); 2791 | }) 2792 | ); 2793 | } 2794 | 2795 | setting.addButton(button => button 2796 | .setIcon('trash') 2797 | .setTooltip('Delete condition') 2798 | .setWarning() 2799 | .onClick(async () => { 2800 | rule.conditions?.splice(index, 1); 2801 | await this.plugin.saveSettings(); 2802 | this.display(); 2803 | }) 2804 | ); 2805 | } 2806 | } 2807 | --------------------------------------------------------------------------------