Added option to add rules based on tags rather than folders

This commit is contained in:
Signynt 2025-05-29 12:12:01 +02:00
parent 70882c912f
commit a67057a673

437
main.ts
View file

@ -7,12 +7,20 @@ import {
MarkdownRenderer,
AbstractInputSuggest,
Component,
TFile
TFile,
getAllTags // Ensure getAllTags is imported
} from 'obsidian';
// Interfaces
interface Rule {
type: 'folder' | 'tag';
path?: string; // For folder type
tag?: string; // For tag type
footerText: string;
}
interface VirtualFooterSettings {
rules: { folderPath: string; footerText: string }[];
rules: Rule[];
renderLocation: 'footer' | 'header';
}
@ -22,7 +30,7 @@ interface HTMLElementWithComponent extends HTMLElement {
// Constants
const DEFAULT_SETTINGS: VirtualFooterSettings = {
rules: [{ folderPath: '', footerText: '' }],
rules: [{ type: 'folder', path: '', footerText: '' }], // Default to one empty folder rule
renderLocation: 'footer'
};
@ -79,118 +87,70 @@ export class MultiSuggest extends AbstractInputSuggest<string> {
/**
* Main plugin class for VirtualFooter.
* This plugin allows users to define custom footer (or header) content
* that is dynamically injected into Markdown views based on folder-specific rules.
* that is dynamically injected into Markdown views based on folder-specific or tag-specific rules.
*/
export default class VirtualFooterPlugin extends Plugin {
/**
* Stores the plugin's settings, loaded from and saved to Obsidian's data storage.
* Contains rules for matching folders to footer text and other display preferences.
*/
settings: VirtualFooterSettings;
/**
* Called when the plugin is first loaded.
* Initializes the plugin by loading settings, adding a settings tab,
* and registering event listeners for file opening and layout changes
* to dynamically update views with virtual footers/headers.
*/
async onload() {
await this.loadSettings();
this.addSettingTab(new VirtualFooterSettingTab(this.app, this));
// Register event listeners to update views when files are opened or layout changes
this.registerEvent(
this.app.workspace.on('file-open', () => this.handleActiveViewChange())
);
this.registerEvent(
this.app.workspace.on('layout-change', () => this.handleActiveViewChange())
);
this.handleActiveViewChange(); // Initial call to process any already open files
this.handleActiveViewChange();
}
/**
* Called when the plugin is unloaded.
* Performs cleanup tasks, including removing all injected content and styles
* from all Markdown views and cleaning up any globally applied CSS classes
* or dynamically created elements.
*/
async onunload() {
this.clearAllViews(); // Clean up all individual views
this.clearAllViews();
// Global cleanup for any elements potentially missed by view-specific cleanup
// This targets dynamically created content elements.
document.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => {
const componentHolder = el as HTMLElementWithComponent;
if (componentHolder.component) {
componentHolder.component.unload(); // Unload associated Obsidian component
componentHolder.component.unload();
}
el.remove(); // Remove the element from the DOM
el.remove();
});
// Remove global CSS classes applied for styling
document.querySelectorAll(`.${CSS_VIRTUAL_FOOTER_CM_PADDING}`).forEach(el => el.classList.remove(CSS_VIRTUAL_FOOTER_CM_PADDING));
document.querySelectorAll(`.${CSS_VIRTUAL_FOOTER_REMOVE_FLEX}`).forEach(el => el.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX));
}
// --- Core View Handling ---
/**
* Handles changes in the active view (e.g., opening a new file, switching tabs).
* It identifies the active Markdown view and triggers processing for it.
*/
handleActiveViewChange() {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
this._processView(activeView);
}
/**
* Processes a given Markdown view to apply or remove virtual footer/header content.
* If the view is invalid or has no file, it clears all views.
* Otherwise, it cleans up existing content/styles from the view,
* applies specific styles for Live Preview footer mode if applicable,
* and then renders and injects new content if the view is in Preview or Live Preview editor mode.
* @param view The MarkdownView to process, or null if no Markdown view is active.
*/
private async _processView(view: MarkdownView | null) {
if (!view || !view.file) {
this.clearAllViews(); // If no valid view, clear everything
this.clearAllViews();
return;
}
// Always clean up the current view before applying new content/styles
await this.removeStylesAndInjectedContent(view);
const state = view.getState();
const isRenderInHeader = this.settings.renderLocation === 'header';
// Apply specific styles for Live Preview footer mode
// These styles adjust padding to make space for the footer.
if (state.mode === 'source' && !state.source && !isRenderInHeader) { // Live Preview editor mode, footer rendering
if (state.mode === 'source' && !state.source && !isRenderInHeader) {
this.applyLivePreviewFooterStyles(view);
}
// Inject content if in Preview mode or Live Preview editor mode
if (state.mode === 'preview' || (state.mode === 'source' && !state.source)) {
await this.renderAndInjectContent(view);
}
}
// --- Content Rendering & Injection ---
/**
* Renders the appropriate footer/header content for the given view and injects it into the DOM.
* It determines the content text based on the file path, prepares the HTML element,
* and then injects it into the correct location (header or footer area) depending on the
* view mode (Preview or Live Preview) and plugin settings.
* Also attaches handlers for internal links within the injected content.
* @param view The MarkdownView where the content will be injected.
*/
private async renderAndInjectContent(view: MarkdownView) {
const filePath = view.file?.path || '';
const contentText = this.getFooterTextForFile(filePath);
if (!contentText) {
await this.removeInjectedContentDOM(view); // Ensure removal if content becomes empty
await this.removeInjectedContentDOM(view);
return;
}
@ -201,7 +161,6 @@ export default class VirtualFooterPlugin extends Plugin {
const state = view.getState();
if (state.mode === 'preview') {
// Target the preview mode's header or footer area
const targetParent = view.containerEl.querySelector<HTMLElement>(
isRenderInHeader ? SELECTOR_PREVIEW_HEADER_AREA : SELECTOR_PREVIEW_FOOTER_AREA
);
@ -211,14 +170,12 @@ export default class VirtualFooterPlugin extends Plugin {
}
} else if (state.mode === 'source' && !state.source) { // Live Preview editor mode
if (isRenderInHeader) {
// Target the area before the CodeMirror content container for header in Live Preview
const cmContentContainer = view.containerEl.querySelector<HTMLElement>(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER);
if (cmContentContainer?.parentElement) {
cmContentContainer.parentElement.insertBefore(contentDiv, cmContentContainer);
injectionSuccessful = true;
}
} else { // Footer in Live Preview editor
// Target the sizer element which is a reliable parent for appending footer content
} else {
const targetParent = view.containerEl.querySelector<HTMLElement>(SELECTOR_EDITOR_SIZER);
if (targetParent) {
targetParent.appendChild(contentDiv);
@ -228,61 +185,32 @@ export default class VirtualFooterPlugin extends Plugin {
}
if (injectionSuccessful) {
// If content was successfully injected, attach internal link handlers
this.attachInternalLinkHandlers(contentDiv, filePath, component, view);
this.attachInternalLinkHandlers(contentDiv, filePath, component);
} else {
// If injection failed (e.g., target element not found), unload the component to free resources.
// console.warn('VirtualFooterPlugin: Target for injection not found. Content not rendered.');
component.unload();
}
}
/**
* Creates and prepares an HTML element to hold the rendered Markdown content.
* It sets up a `div` with appropriate CSS classes, creates an Obsidian `Component`
* for lifecycle management of the rendered content (e.g., internal links),
* and then renders the provided Markdown text into this `div`.
* @param contentText The Markdown string to render.
* @param isRenderInHeader True if the content is for the header, false for the footer.
* @param sourcePath The path of the file for which this content is being rendered, used for Markdown rendering context.
* @returns A promise that resolves to an object containing the created HTMLElement and its associated Component.
*/
private async prepareContentElement(contentText: string, isRenderInHeader: boolean, sourcePath: string): Promise<{ element: HTMLElement; component: Component }> {
const contentDiv = document.createElement('div');
contentDiv.className = CSS_DYNAMIC_CONTENT_ELEMENT; // Base class for all dynamic content
contentDiv.classList.add(isRenderInHeader ? CSS_HEADER_RENDERED_CONTENT : CSS_FOOTER_RENDERED_CONTENT); // Specific class for header/footer
contentDiv.className = CSS_DYNAMIC_CONTENT_ELEMENT;
contentDiv.classList.add(isRenderInHeader ? CSS_HEADER_RENDERED_CONTENT : CSS_FOOTER_RENDERED_CONTENT);
// Create a new component to manage the lifecycle of the rendered Markdown.
// This is important for Obsidian's internal link handling and other dynamic features.
const component = new Component();
component.load(); // Load the component
(contentDiv as HTMLElementWithComponent).component = component; // Store component reference on the element
component.load();
(contentDiv as HTMLElementWithComponent).component = component;
// Render the Markdown content into the div.
await MarkdownRenderer.render(this.app, contentText, contentDiv, sourcePath, component);
return { element: contentDiv, component };
}
// --- DOM Styling & Cleanup ---
/**
* Applies specific CSS classes to elements within a MarkdownView when rendering
* a footer in Live Preview mode. These classes adjust padding and layout
* to accommodate the injected footer content.
* @param view The MarkdownView to style.
*/
private applyLivePreviewFooterStyles(view: MarkdownView): void {
const contentEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_AREA);
const containerEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT);
contentEl?.classList.add(CSS_VIRTUAL_FOOTER_CM_PADDING); // Adds bottom padding to CodeMirror content
containerEl?.classList.add(CSS_VIRTUAL_FOOTER_REMOVE_FLEX); // Modifies flex behavior of parent container
contentEl?.classList.add(CSS_VIRTUAL_FOOTER_CM_PADDING);
containerEl?.classList.add(CSS_VIRTUAL_FOOTER_REMOVE_FLEX);
}
/**
* Removes the CSS classes previously applied by `applyLivePreviewFooterStyles`.
* This is used to clean up styles when the footer is removed or the view mode changes.
* @param view The MarkdownView from which to remove styles.
*/
private removeLivePreviewFooterStyles(view: MarkdownView): void {
const contentEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_AREA);
const containerEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT);
@ -290,40 +218,24 @@ export default class VirtualFooterPlugin extends Plugin {
containerEl?.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX);
}
/**
* Removes all dynamically injected content elements (footers/headers) from a specific view.
* It iterates through known parent selectors where content might be injected,
* finds elements with the `CSS_DYNAMIC_CONTENT_ELEMENT` class, unloads their
* associated Obsidian components, and then removes the elements from the DOM.
* @param view The MarkdownView from which to remove injected content.
*/
private async removeInjectedContentDOM(view: MarkdownView) {
SELECTORS_POTENTIAL_DYNAMIC_CONTENT_PARENTS.forEach(selector => {
const parentEl = view.containerEl.querySelector(selector);
parentEl?.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => {
const componentHolder = el as HTMLElementWithComponent;
if (componentHolder.component) {
componentHolder.component.unload(); // Unload the component to free resources
componentHolder.component.unload();
}
el.remove(); // Remove the element from the DOM
el.remove();
});
});
}
/**
* A utility method to remove both applied Live Preview styles and injected DOM content
* from a given Markdown view.
* @param view The MarkdownView to clean up.
*/
private async removeStylesAndInjectedContent(view: MarkdownView) {
this.removeLivePreviewFooterStyles(view);
await this.removeInjectedContentDOM(view);
}
/**
* Clears all injected content and styles from all open Markdown views in the workspace.
* Iterates through all 'markdown' type leaves and calls `removeStylesAndInjectedContent` for each.
*/
private clearAllViews(): void {
this.app.workspace.getLeavesOfType('markdown').forEach(leaf => {
if (leaf.view instanceof MarkdownView) {
@ -332,66 +244,141 @@ export default class VirtualFooterPlugin extends Plugin {
});
}
// --- Utility Methods ---
/**
* Determines the appropriate footer/header text for a given file path based on the
* plugin's configured rules. It finds the rule with the longest matching folder path.
* @param filePath The path of the file to find footer text for.
* @returns The footer/header text string if a matching rule is found, otherwise an empty string.
*/
private getFooterTextForFile(filePath: string): string {
let bestMatchPath = '';
let footerText = '';
// Iterate through rules to find the most specific match (longest folder path)
let bestMatchPath = ""; // Stores the path string of the best match, "" is least specific
let folderRuleText = "";
// Check folder rules first
for (const rule of this.settings.rules) {
if (filePath.startsWith(rule.folderPath) && rule.folderPath.length >= bestMatchPath.length) {
bestMatchPath = rule.folderPath;
footerText = rule.footerText;
if (rule.type === 'folder' && rule.path !== undefined) {
let isMatch = false;
let currentRuleSpecificity = -1;
if (rule.path === "") { // Empty string path rule applies to all files
isMatch = true;
currentRuleSpecificity = 0; // Least specific
} else if (rule.path === "/") { // Root folder path rule
const fileForPath = this.app.vault.getAbstractFileByPath(filePath);
if (fileForPath instanceof TFile && fileForPath.parent && fileForPath.parent.isRoot()) {
isMatch = true;
currentRuleSpecificity = 1; // More specific than ""
}
} else { // Regular folder path (e.g., "Meetings/")
if (filePath.startsWith(rule.path)) {
isMatch = true;
currentRuleSpecificity = rule.path.length;
}
}
if (isMatch) {
const bestMatchSpecificity = (bestMatchPath === "") ? 0 : (bestMatchPath === "/" ? 1 : bestMatchPath.length);
if (currentRuleSpecificity >= bestMatchSpecificity) {
bestMatchPath = rule.path;
folderRuleText = rule.footerText;
}
}
}
}
return footerText;
if (folderRuleText) {
return folderRuleText; // Folder rule takes precedence
}
// If no folder rule matched, check tag rules
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
const fileCache = this.app.metadataCache.getFileCache(file);
if (fileCache) {
// Use getAllTags to include frontmatter tags. It returns tags with '#'.
const allTagsInFileWithHash = getAllTags(fileCache);
const fileTags = allTagsInFileWithHash ? allTagsInFileWithHash.map(tag => tag.substring(1)) : []; // Remove leading '#'
for (const rule of this.settings.rules) {
if (rule.type === 'tag' && rule.tag && fileTags.includes(rule.tag)) {
return rule.footerText; // First matching tag rule
}
}
}
}
return ''; // No rule matched
}
/**
* Attaches click event handlers to internal links (`<a class="internal-link">`)
* within the injected content container. This ensures that Obsidian's internal link
* navigation (including opening in new panes with Ctrl/Cmd click) works correctly.
* @param container The HTMLElement containing the rendered Markdown content.
* @param sourcePath The path of the file where the content is displayed, used as context for link resolution.
* @param component The Obsidian Component associated with the rendered content, used to register DOM events.
* @param view The MarkdownView instance, currently unused but kept for potential future use.
*/
private attachInternalLinkHandlers(container: HTMLElement, sourcePath: string, component: Component, view: MarkdownView) {
// Register a DOM event listener on the component for click events within the container.
private attachInternalLinkHandlers(container: HTMLElement, sourcePath: string, component: Component) {
// Handle left-clicks and Ctrl/Meta + left-clicks
component.registerDomEvent(container, 'click', (event: MouseEvent) => {
// Ensure it's a left click (button 0)
if (event.button !== 0) {
return;
}
const target = event.target as HTMLElement;
const link = target.closest('a.internal-link') as HTMLAnchorElement; // Find the closest internal link ancestor
const link = target.closest('a.internal-link') as HTMLAnchorElement;
if (link) {
event.preventDefault(); // Prevent default link navigation
const href = link.dataset.href; // Get the link destination from data-href attribute
event.preventDefault(); // Prevent default navigation for all captured internal links
const href = link.dataset.href;
if (href) {
const newPane = event.ctrlKey || event.metaKey; // Check for Ctrl/Cmd key for new pane
this.app.workspace.openLinkText(href, sourcePath, newPane); // Use Obsidian API to open link
const newPane = event.ctrlKey || event.metaKey; // For left click, new pane is only for Ctrl/Meta
this.app.workspace.openLinkText(href, sourcePath, newPane);
}
}
});
// Handle middle-clicks (auxiliary clicks)
component.registerDomEvent(container, 'auxclick', (event: MouseEvent) => {
// Ensure it's a middle click (button 1)
if (event.button !== 1) {
return;
}
const target = event.target as HTMLElement;
const link = target.closest('a.internal-link') as HTMLAnchorElement;
if (link) {
event.preventDefault(); // Prevent default middle-click behavior (e.g., autoscroll)
const href = link.dataset.href;
if (href) {
// Middle click always opens in a new pane
this.app.workspace.openLinkText(href, sourcePath, true);
}
}
});
}
// --- Settings Persistence ---
/**
* Loads plugin settings from Obsidian's data storage.
* Merges stored settings with default settings to ensure all properties are present.
*/
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
const loadedSettings = await this.loadData();
if (loadedSettings && loadedSettings.rules) {
// Migration for rules that don't have a 'type' (from older versions)
loadedSettings.rules = loadedSettings.rules.map((rule: any) => {
if (typeof rule.folderPath === 'string' && typeof rule.type === 'undefined') {
return {
type: 'folder',
path: rule.folderPath, // Use 'path' instead of 'folderPath'
footerText: rule.footerText
};
}
// Ensure new rules have path/tag initialized if not present
if (rule.type === 'folder' && rule.path === undefined) rule.path = '';
if (rule.type === 'tag' && rule.tag === undefined) rule.tag = '';
return rule;
});
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
// Ensure default rule structure if rules array is empty or missing after loading
if (!this.settings.rules || this.settings.rules.length === 0) {
this.settings.rules = [{ type: 'folder', path: '', footerText: '' }];
} else {
// Final check to ensure all rules have a type and initialized path/tag
this.settings.rules.forEach(rule => {
if (!rule.type) rule.type = 'folder'; // Default to folder if somehow still missing
if (rule.type === 'folder' && rule.path === undefined) rule.path = '';
if (rule.type === 'tag' && rule.tag === undefined) rule.tag = '';
});
}
}
/**
* Saves the current plugin settings to Obsidian's data storage.
*/
async saveSettings() {
await this.saveData(this.settings);
}
@ -400,6 +387,7 @@ export default class VirtualFooterPlugin extends Plugin {
class VirtualFooterSettingTab extends PluginSettingTab {
plugin: VirtualFooterPlugin;
private allFilePaths: Set<string> | null = null;
private allTagsCache: Set<string> | null = null;
constructor(app: App, plugin: VirtualFooterPlugin) {
super(app, plugin);
@ -409,22 +397,45 @@ class VirtualFooterSettingTab extends PluginSettingTab {
private generateAllFilePaths(): Set<string> {
if (this.allFilePaths) return this.allFilePaths;
const paths = new Set<string>(['/']);
const paths = new Set<string>(['/']); // Add root path
this.plugin.app.vault.getAllLoadedFiles().forEach(file => {
if (file instanceof TFile && file.parent) {
paths.add(file.parent.path === '/' ? '/' : file.parent.path + '/');
const parentPath = file.parent.isRoot() ? '/' : (file.parent.path.endsWith('/') ? file.parent.path : file.parent.path + '/');
if (parentPath !== '/') paths.add(parentPath);
} else if ('children' in file && file.path !== '/') { // TFolder
paths.add(file.path + '/');
const folderPath = file.path.endsWith('/') ? file.path : file.path + '/';
paths.add(folderPath);
}
});
this.allFilePaths = paths;
return paths;
}
private generateAllTagsInVault(): Set<string> {
if (this.allTagsCache) return this.allTagsCache;
const collectedTags = new Set<string>();
const markdownFiles = this.plugin.app.vault.getMarkdownFiles();
for (const file of markdownFiles) {
const fileCache = this.plugin.app.metadataCache.getFileCache(file);
if (fileCache) {
const tagsInFile = getAllTags(fileCache);
if (tagsInFile) {
tagsInFile.forEach(tag => {
collectedTags.add(tag.startsWith('#') ? tag.substring(1) : tag);
});
}
}
}
this.allTagsCache = collectedTags;
return collectedTags;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
this.allFilePaths = null; // Reset cached paths for re-display
this.allFilePaths = null;
this.allTagsCache = null;
new Setting(containerEl)
.setName('Render location')
@ -445,6 +456,10 @@ class VirtualFooterSettingTab extends PluginSettingTab {
if (!this.plugin.settings.rules) {
this.plugin.settings.rules = [];
}
if (this.plugin.settings.rules.length === 0) {
this.plugin.settings.rules.push({ type: 'folder', path: '', footerText: '' });
}
this.plugin.settings.rules.forEach((rule, index) => {
this.renderRuleControls(rule, index, rulesContainer);
@ -455,48 +470,90 @@ class VirtualFooterSettingTab extends PluginSettingTab {
.setButtonText('Add rule')
.setClass('virtual-footer-add-button')
.onClick(async () => {
this.plugin.settings.rules.push({ folderPath: '', footerText: '' });
this.plugin.settings.rules.push({ type: 'folder', path: '', footerText: '' });
await this.plugin.saveSettings();
this.display(); // Re-render the entire settings tab
this.plugin.handleActiveViewChange(); // Update views
this.display();
this.plugin.handleActiveViewChange();
}));
}
private renderRuleControls(rule: { folderPath: string; footerText: string }, index: number, containerEl: HTMLElement) {
private renderRuleControls(rule: Rule, index: number, containerEl: HTMLElement) {
const ruleDiv = containerEl.createDiv();
ruleDiv.addClass('rule');
new Setting(ruleDiv)
.setName(`Folder path ${index + 1}`)
.setDesc('Path in the vault. Content will apply to notes in this folder and its subfolders. Use "/" for all notes.')
.addText(text => {
text.setPlaceholder('e.g., Meetings/ or /')
.setValue(rule.folderPath)
.onChange(async (value) => {
this.plugin.settings.rules[index].folderPath = value;
await this.plugin.saveSettings();
this.plugin.handleActiveViewChange();
});
new MultiSuggest(text.inputEl, this.generateAllFilePaths(), (selectedPath) => {
this.plugin.settings.rules[index].folderPath = selectedPath;
this.plugin.saveSettings(); // await not strictly needed if not chaining
.setName(`Rule ${index + 1} type`)
.addDropdown(dropdown => dropdown
.addOption('folder', 'Folder')
.addOption('tag', 'Tag')
.setValue(rule.type)
.onChange(async (value: 'folder' | 'tag') => {
rule.type = value;
if (value === 'folder') {
delete rule.tag;
if (rule.path === undefined) rule.path = '';
} else {
delete rule.path;
if (rule.tag === undefined) rule.tag = '';
}
await this.plugin.saveSettings();
this.display();
this.plugin.handleActiveViewChange();
text.setValue(selectedPath); // Ensure text input updates
}, this.plugin.app);
});
}));
if (rule.type === 'folder') {
new Setting(ruleDiv)
.setName(`Folder path`)
.setDesc('Path for the rule. Use "" for all files, "/" for root folder files, or "FolderName/" for specific folders and their subfolders.')
.addText(text => {
text.setPlaceholder('e.g., Meetings/, /, or leave empty for all')
.setValue(rule.path || '')
.onChange(async (value) => {
rule.path = value;
await this.plugin.saveSettings();
this.plugin.handleActiveViewChange();
});
new MultiSuggest(text.inputEl, this.generateAllFilePaths(), (selectedPath) => {
rule.path = selectedPath;
this.plugin.saveSettings();
this.plugin.handleActiveViewChange();
text.setValue(selectedPath);
}, this.plugin.app);
});
} else if (rule.type === 'tag') {
new Setting(ruleDiv)
.setName(`Tag value`)
.setDesc('Tag to match (without the #).')
.addText(text => {
text.setPlaceholder('e.g., important or project/alpha')
.setValue(rule.tag || '')
.onChange(async (value) => {
rule.tag = value.startsWith('#') ? value.substring(1) : value;
await this.plugin.saveSettings();
this.plugin.handleActiveViewChange();
});
new MultiSuggest(text.inputEl, this.generateAllTagsInVault(), (selectedTag) => {
const normalizedTag = selectedTag.startsWith('#') ? selectedTag.substring(1) : selectedTag;
rule.tag = normalizedTag;
this.plugin.saveSettings();
this.plugin.handleActiveViewChange();
text.setValue(normalizedTag);
}, this.plugin.app);
});
}
new Setting(ruleDiv)
.setName(`Content text ${index + 1}`)
.setName(`Content text`)
.setDesc('Markdown text to display.')
.addTextArea(text => text
.setPlaceholder('Enter your markdown content here...')
.setValue(rule.footerText)
.onChange(async (value) => {
this.plugin.settings.rules[index].footerText = value;
rule.footerText = value;
await this.plugin.saveSettings();
this.plugin.handleActiveViewChange();
}));
new Setting(ruleDiv)
.addButton(button => button
.setButtonText('Delete rule')
@ -504,10 +561,10 @@ class VirtualFooterSettingTab extends PluginSettingTab {
.onClick(async () => {
this.plugin.settings.rules.splice(index, 1);
await this.plugin.saveSettings();
this.display(); // Re-render the entire settings tab
this.plugin.handleActiveViewChange(); // Update views
this.display();
this.plugin.handleActiveViewChange();
}));
ruleDiv.createEl('hr');
}
}