mirror of
https://github.com/signynt/virtual-content.git
synced 2026-07-22 05:46:44 +00:00
4161 lines
155 KiB
TypeScript
4161 lines
155 KiB
TypeScript
import {
|
|
App,
|
|
Plugin,
|
|
PluginSettingTab,
|
|
Setting,
|
|
Modal,
|
|
ButtonComponent,
|
|
MarkdownView,
|
|
MarkdownRenderer,
|
|
AbstractInputSuggest,
|
|
Component,
|
|
TFile,
|
|
getAllTags,
|
|
ItemView,
|
|
WorkspaceLeaf,
|
|
} from 'obsidian';
|
|
|
|
// --- Enums ---
|
|
|
|
/** Defines the type of a rule, determining how it matches files (e.g., by folder, tag, or property). */
|
|
enum RuleType {
|
|
Folder = 'folder',
|
|
Tag = 'tag',
|
|
Property = 'property',
|
|
Multi = 'multi',
|
|
Dataview = 'dataview',
|
|
}
|
|
|
|
/** Defines the source of the content for a rule (e.g., direct text input or a markdown file). */
|
|
enum ContentSource {
|
|
Text = 'text',
|
|
File = 'file',
|
|
}
|
|
|
|
/** Defines where the dynamic content should be rendered within the Markdown view (e.g., header or footer). */
|
|
enum RenderLocation {
|
|
Footer = 'footer',
|
|
Header = 'header',
|
|
Sidebar = 'sidebar',
|
|
SectionHeader = 'section-header',
|
|
}
|
|
|
|
type SectionHeaderPlacement = 'top' | 'bottom';
|
|
|
|
// --- Interfaces ---
|
|
|
|
/**
|
|
* Represents a single condition for a 'Multi' rule type.
|
|
*/
|
|
interface SubCondition {
|
|
/** The type of condition (folder, tag, or property). */
|
|
type: 'folder' | 'tag' | 'property';
|
|
/** Whether this condition should be negated (not met). Defaults to false. */
|
|
negated?: boolean;
|
|
/** For 'folder' type: path to the folder. */
|
|
path?: string;
|
|
/** For 'folder' type: whether to match subfolders. */
|
|
recursive?: boolean;
|
|
/** For 'tag' type: the tag name (without '#'). */
|
|
tag?: string;
|
|
/** For 'tag' type: whether to match subtags. */
|
|
includeSubtags?: boolean;
|
|
/** For 'property' type: the name of the frontmatter property. */
|
|
propertyName?: string;
|
|
/** For 'property' type: the value the frontmatter property should have. */
|
|
propertyValue?: string;
|
|
}
|
|
|
|
/**
|
|
* Represents a rule for injecting dynamic content into Markdown views.
|
|
* Each rule specifies matching criteria (type: folder/tag/property), content source (text/file),
|
|
* the content itself, and where it should be rendered (header/footer).
|
|
*/
|
|
interface Rule {
|
|
/** A descriptive name for this rule. */
|
|
name?: string;
|
|
/** Whether this rule is currently active. */
|
|
enabled?: boolean;
|
|
/** The type of criteria for this rule (folder-based, tag-based, or property-based). */
|
|
type: RuleType;
|
|
/** Whether this rule's condition should be negated (not met). Defaults to false. */
|
|
negated?: boolean;
|
|
/** For 'folder' type: path to the folder. "" for all files, "/" for root. */
|
|
path?: string;
|
|
/** For 'tag' type: the tag name (without '#'). */
|
|
tag?: string;
|
|
/** For 'folder' type: whether to match subfolders. Defaults to true. Ignored if path is "". */
|
|
recursive?: boolean;
|
|
/** For 'tag' type: whether to match subtags (e.g., 'tag' matches 'tag/subtag'). Defaults to false. */
|
|
includeSubtags?: boolean;
|
|
/** For 'property' type: the name of the frontmatter property. */
|
|
propertyName?: string;
|
|
/** For 'property' type: the value the frontmatter property should have. */
|
|
propertyValue?: string;
|
|
/** For 'multi' type: an array of sub-conditions. */
|
|
conditions?: SubCondition[];
|
|
/** For 'multi' type: specifies whether ANY or ALL conditions must be met. Defaults to 'any'. */
|
|
multiConditionLogic?: 'any' | 'all';
|
|
/** For 'dataview' type: the Dataview query to use for matching files. */
|
|
dataviewQuery?: string;
|
|
/** The source from which to get the content (direct text or a file). */
|
|
contentSource: ContentSource;
|
|
/** Direct text content if contentSource is 'text'. */
|
|
footerText: string; // Retained name for compatibility, though it can be header or footer content.
|
|
/** Path to a .md file if contentSource is 'file'. */
|
|
footerFilePath?: string; // Retained name for compatibility.
|
|
/** Specifies whether to render in the header or footer. */
|
|
renderLocation: RenderLocation;
|
|
/** For 'sidebar' location: whether to show in a separate tab. */
|
|
showInSeparateTab?: boolean;
|
|
/** For 'sidebar' location: the name of the separate tab. */
|
|
sidebarTabName?: string;
|
|
/** For 'header' location: whether to render above the properties section. */
|
|
renderAboveProperties?: boolean;
|
|
/** For 'footer' location: whether to render above the backlinks section. */
|
|
renderAboveBacklinks?: boolean;
|
|
/** For 'section-header' location: the heading text to target. */
|
|
sectionHeaderText?: string;
|
|
/** For 'section-header' location: the heading level to target, e.g. h2. */
|
|
sectionHeaderLevel?: string;
|
|
/** For 'section-header' location: whether to render at the top or bottom of the section. */
|
|
sectionHeaderPlacement?: SectionHeaderPlacement;
|
|
/** Whether to show this rule's content in popover views. */
|
|
showInPopover?: boolean;
|
|
/** Whether to show this rule's content in embedded notes. */
|
|
showInEmbed?: boolean;
|
|
/** Whether to show this rule's content in canvas card previews. */
|
|
showInCanvas?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Defines the settings structure for the VirtualFooter plugin.
|
|
* Contains an array of rules that dictate content injection.
|
|
*/
|
|
interface VirtualFooterSettings {
|
|
rules: Rule[];
|
|
/** Whether to refresh the view on file open. Defaults to false. */
|
|
refreshOnFileOpen?: boolean;
|
|
/** Whether to render content in source mode. Defaults to false. */
|
|
renderInSourceMode?: boolean;
|
|
/** Whether to refresh the view when note metadata changes. Defaults to false. */
|
|
refreshOnMetadataChange?: boolean;
|
|
/** Whether to treat property values as links for matching file targets. */
|
|
smartPropertyLinks?: boolean;
|
|
/** Whether to enable virtual content inside embedded notes. */
|
|
enableEmbedRendering?: boolean;
|
|
/** Whether to enable canvas rendering support (can be expensive). */
|
|
enableCanvasRendering?: boolean;
|
|
/** Whether to log embed/canvas resolution details for debugging. */
|
|
debugEmbedCanvas?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Extends HTMLElement to associate an Obsidian Component for lifecycle management.
|
|
* This allows Obsidian to manage resources tied to the DOM element.
|
|
*/
|
|
interface HTMLElementWithComponent extends HTMLElement {
|
|
/** The Obsidian Component associated with this HTML element. */
|
|
component?: Component;
|
|
/** The MutationObserver for monitoring style changes. */
|
|
observer?: MutationObserver;
|
|
}
|
|
|
|
// --- Constants ---
|
|
|
|
/** Default settings for the plugin, used when no settings are found or for new rules. */
|
|
const DEFAULT_SETTINGS: VirtualFooterSettings = {
|
|
rules: [{
|
|
name: 'Default Rule',
|
|
enabled: true,
|
|
type: RuleType.Folder,
|
|
negated: false,
|
|
path: '', // Matches all files by default
|
|
recursive: true,
|
|
contentSource: ContentSource.Text,
|
|
footerText: '', // Default content is empty
|
|
renderLocation: RenderLocation.Footer,
|
|
showInSeparateTab: false,
|
|
sidebarTabName: '',
|
|
multiConditionLogic: 'any',
|
|
renderAboveProperties: false,
|
|
renderAboveBacklinks: false,
|
|
sectionHeaderText: '',
|
|
sectionHeaderLevel: 'h2',
|
|
sectionHeaderPlacement: 'top',
|
|
showInPopover: true,
|
|
showInEmbed: true,
|
|
showInCanvas: true,
|
|
}],
|
|
refreshOnFileOpen: false, // Default to false
|
|
renderInSourceMode: false, // Default to false
|
|
refreshOnMetadataChange: false, // Default to false
|
|
smartPropertyLinks: false, // Default to false
|
|
enableEmbedRendering: false, // Default to false
|
|
enableCanvasRendering: false, // Default to false
|
|
debugEmbedCanvas: false, // Default to false
|
|
};
|
|
|
|
// CSS Classes for styling and identifying plugin-generated elements
|
|
const CSS_DYNAMIC_CONTENT_ELEMENT = 'virtual-footer-dynamic-content-element';
|
|
const CSS_HEADER_GROUP_ELEMENT = 'virtual-footer-header-group';
|
|
const CSS_FOOTER_GROUP_ELEMENT = 'virtual-footer-footer-group';
|
|
const CSS_HEADER_RENDERED_CONTENT = 'virtual-footer-header-rendered-content';
|
|
const CSS_FOOTER_RENDERED_CONTENT = 'virtual-footer-footer-rendered-content';
|
|
const CSS_SECTION_HEADER_GROUP_ELEMENT = 'virtual-footer-section-header-group';
|
|
const CSS_VIRTUAL_FOOTER_CM_PADDING = 'virtual-footer-cm-padding'; // For CodeMirror live preview footer spacing
|
|
const CSS_VIRTUAL_FOOTER_REMOVE_FLEX = 'virtual-footer-remove-flex'; // For CodeMirror live preview footer layout
|
|
const CSS_ABOVE_BACKLINKS = 'virtual-footer-above-backlinks'; // For removing min-height when above backlinks
|
|
const CSS_VIRTUAL_FOOTER_BOTTOM_PADDING_VAR = '--virtual-footer-bottom-padding';
|
|
const VIRTUAL_FOOTER_BOTTOM_PADDING_MIN = 200;
|
|
const VIRTUAL_FOOTER_BOTTOM_PADDING_MAX = 700;
|
|
const VIRTUAL_FOOTER_BOTTOM_PADDING_RATIO = 0.6;
|
|
|
|
// DOM Selectors for targeting elements in Obsidian's interface
|
|
const SELECTOR_EDITOR_CONTENT_AREA = '.cm-editor .cm-content';
|
|
const SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT = '.markdown-source-view.mod-cm6 .cm-contentContainer';
|
|
const SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER = '.cm-contentContainer';
|
|
const SELECTOR_EDITOR_SIZER = '.cm-sizer'; // Target for live preview footer injection
|
|
const SELECTOR_PREVIEW_HEADER_AREA = '.mod-header.mod-ui'; // Target for reading mode header injection
|
|
const SELECTOR_PREVIEW_FOOTER_AREA = '.mod-footer'; // Target for reading mode footer injection
|
|
const SELECTOR_EMBEDDED_BACKLINKS = '.embedded-backlinks'; // Target for positioning above backlinks
|
|
const SELECTOR_METADATA_CONTAINER = '.metadata-container'; // Target for positioning above properties
|
|
|
|
const VIRTUAL_CONTENT_VIEW_TYPE = 'virtual-content-view';
|
|
const VIRTUAL_CONTENT_SEPARATE_VIEW_TYPE_PREFIX = 'virtual-content-separate-view-';
|
|
|
|
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return value !== 0;
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (['true', '1', 'yes', 'on', 'not'].includes(normalized)) return true;
|
|
if (['false', '0', 'no', 'off', 'is', ''].includes(normalized)) return false;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// --- Utility Classes ---
|
|
|
|
/**
|
|
* A suggestion provider for input fields, offering autocompletion from a given set of strings.
|
|
*/
|
|
export class MultiSuggest extends AbstractInputSuggest<string> {
|
|
/**
|
|
* Creates an instance of MultiSuggest.
|
|
* @param inputEl The HTML input element to attach the suggester to.
|
|
* @param content The set of strings to use as suggestions.
|
|
* @param onSelectCb Callback function executed when a suggestion is selected.
|
|
* @param app The Obsidian App instance.
|
|
*/
|
|
constructor(
|
|
private inputEl: HTMLInputElement,
|
|
private content: Set<string>,
|
|
private onSelectCb: (value: string) => void,
|
|
app: App
|
|
) {
|
|
super(app, inputEl);
|
|
}
|
|
|
|
/**
|
|
* Filters the content set to find suggestions matching the input string.
|
|
* @param inputStr The current string in the input field.
|
|
* @returns An array of matching suggestion strings.
|
|
*/
|
|
getSuggestions(inputStr: string): string[] {
|
|
const lowerCaseInputStr = inputStr.toLocaleLowerCase();
|
|
return [...this.content].filter((contentItem) =>
|
|
contentItem.toLocaleLowerCase().includes(lowerCaseInputStr)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders a single suggestion item in the suggestion list.
|
|
* @param content The suggestion string to render.
|
|
* @param el The HTMLElement to render the suggestion into.
|
|
*/
|
|
renderSuggestion(content: string, el: HTMLElement): void {
|
|
el.setText(content);
|
|
}
|
|
|
|
/**
|
|
* Handles the selection of a suggestion.
|
|
* @param content The selected suggestion string.
|
|
* @param _evt The mouse or keyboard event that triggered the selection.
|
|
*/
|
|
selectSuggestion(content: string, _evt: MouseEvent | KeyboardEvent): void {
|
|
this.onSelectCb(content);
|
|
this.inputEl.value = content; // Update input field with selected value
|
|
this.inputEl.blur(); // Remove focus from input
|
|
this.close(); // Close the suggestion popover
|
|
}
|
|
}
|
|
|
|
// --- Sidebar View Class ---
|
|
|
|
export class VirtualContentView extends ItemView {
|
|
plugin: VirtualFooterPlugin;
|
|
viewContent: HTMLElement | null = null;
|
|
component: Component = new Component();
|
|
private contentProvider: () => { content: string, sourcePath: string } | null;
|
|
private viewId: string;
|
|
private tabName: string;
|
|
|
|
constructor(leaf: WorkspaceLeaf, plugin: VirtualFooterPlugin, viewId: string, tabName: string, contentProvider: () => { content: string, sourcePath: string } | null) {
|
|
super(leaf);
|
|
this.plugin = plugin;
|
|
this.viewId = viewId;
|
|
this.tabName = tabName;
|
|
this.contentProvider = contentProvider;
|
|
}
|
|
|
|
getViewType() {
|
|
return this.viewId;
|
|
}
|
|
|
|
getDisplayText() {
|
|
return this.tabName;
|
|
}
|
|
|
|
getIcon() {
|
|
return 'text-select';
|
|
}
|
|
|
|
protected async onOpen(): Promise<void> {
|
|
this.component = new Component();
|
|
this.component.load();
|
|
|
|
const container = this.containerEl.children[1];
|
|
container.empty();
|
|
this.viewContent = container.createDiv({ cls: 'virtual-content-sidebar-view' });
|
|
this.update();
|
|
}
|
|
|
|
|
|
|
|
protected async onClose(): Promise<void> {
|
|
this.component.unload();
|
|
}
|
|
|
|
update() {
|
|
if (!this.viewContent) return;
|
|
|
|
// Clean up previous content and component
|
|
this.viewContent.empty();
|
|
this.component.unload();
|
|
this.component = new Component();
|
|
this.component.load();
|
|
|
|
const data = this.contentProvider();
|
|
if (data && data.content && data.content.trim() !== '') {
|
|
MarkdownRenderer.render(this.app, data.content, this.viewContent, data.sourcePath, this.component);
|
|
this.plugin.attachInternalLinkHandlers(this.viewContent, data.sourcePath, this.component);
|
|
} else {
|
|
this.viewContent.createEl('p', {
|
|
text: 'No virtual content to display for the current note.',
|
|
cls: 'virtual-content-sidebar-empty'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Main Plugin Class ---
|
|
|
|
/**
|
|
* VirtualFooterPlugin dynamically injects content into the header or footer of Markdown views
|
|
* based on configurable rules.
|
|
*/
|
|
export default class VirtualFooterPlugin extends Plugin {
|
|
settings: VirtualFooterSettings = DEFAULT_SETTINGS;
|
|
/** Stores pending content injections for preview mode, awaiting DOM availability. */
|
|
private pendingPreviewInjections: WeakMap<MarkdownView, {
|
|
headerDiv?: HTMLElementWithComponent,
|
|
footerDiv?: HTMLElementWithComponent,
|
|
headerAbovePropertiesDiv?: HTMLElementWithComponent,
|
|
footerAboveBacklinksDiv?: HTMLElementWithComponent,
|
|
filePath?: string
|
|
}> = new WeakMap();
|
|
/** Manages MutationObservers for views in preview mode to detect when injection targets are ready. */
|
|
private previewObservers: WeakMap<MarkdownView, MutationObserver> = new WeakMap();
|
|
private initialLayoutReadyProcessed = false;
|
|
private lastSidebarContent: { content: string, sourcePath: string } | null = null;
|
|
private lastSeparateTabContents: Map<string, { content: string, sourcePath: string }> = new Map();
|
|
private lastHoveredLink: HTMLElement | null = null;
|
|
private popoverObserver: MutationObserver | null = null;
|
|
private canvasObserver: MutationObserver | null = null;
|
|
private sectionHeaderScrollRefreshTimeout: number | null = null;
|
|
private embedObservers: WeakMap<MarkdownView, MutationObserver> = new WeakMap();
|
|
private embedRefreshTimeouts: WeakMap<MarkdownView, number> = new WeakMap();
|
|
private embedLastScanByView: WeakMap<MarkdownView, { filePath: string; time: number }> = new WeakMap();
|
|
private footerPaddingObservers: WeakMap<MarkdownView, ResizeObserver> = new WeakMap();
|
|
private canvasRefreshTimeout: number | null = null;
|
|
private canvasRefreshInProgress = false;
|
|
private canvasInteractionHandler: ((event: Event) => void) | null = null;
|
|
|
|
private getActiveFileForVirtualContent(): TFile | null {
|
|
const activeLeaf = this.app.workspace.activeLeaf;
|
|
const activeView = activeLeaf?.view as { file?: unknown } | undefined;
|
|
if (activeView?.file instanceof TFile) {
|
|
return activeView.file;
|
|
}
|
|
|
|
const workspaceFile = this.app.workspace.getActiveFile();
|
|
return workspaceFile instanceof TFile ? workspaceFile : null;
|
|
}
|
|
|
|
private async processSidebarContentForFilePath(filePath: string): Promise<void> {
|
|
const applicableRulesWithContent = await this._getApplicableRulesAndContent(filePath);
|
|
const contentSeparator = "\n\n";
|
|
let combinedSidebarText = "";
|
|
this.lastSeparateTabContents.clear();
|
|
|
|
for (const { rule, contentText, index } of applicableRulesWithContent) {
|
|
if (!contentText || contentText.trim() === "" || rule.renderLocation !== RenderLocation.Sidebar) {
|
|
continue;
|
|
}
|
|
|
|
if (rule.showInSeparateTab) {
|
|
const viewId = this.getSeparateViewId(index);
|
|
const existingContent = this.lastSeparateTabContents.get(viewId)?.content || "";
|
|
this.lastSeparateTabContents.set(viewId, {
|
|
content: (existingContent ? existingContent + contentSeparator : "") + contentText,
|
|
sourcePath: filePath
|
|
});
|
|
} else {
|
|
combinedSidebarText += (combinedSidebarText ? contentSeparator : "") + contentText;
|
|
}
|
|
}
|
|
|
|
this.lastSidebarContent = { content: combinedSidebarText, sourcePath: filePath };
|
|
this.updateAllSidebarViews();
|
|
}
|
|
|
|
private async processNonMarkdownActiveFile(): Promise<void> {
|
|
const activeFile = this.getActiveFileForVirtualContent();
|
|
if (!activeFile) {
|
|
if (!this.settings.refreshOnFileOpen || this.app.workspace.getLeavesOfType('markdown').length === 0) {
|
|
this.lastSidebarContent = null;
|
|
this.lastSeparateTabContents.clear();
|
|
this.updateAllSidebarViews();
|
|
}
|
|
return;
|
|
}
|
|
|
|
await this.processSidebarContentForFilePath(activeFile.path);
|
|
}
|
|
|
|
private logEmbedCanvasDebug(message: string, data?: Record<string, unknown>): void {
|
|
if (!this.settings.debugEmbedCanvas) {
|
|
return;
|
|
}
|
|
if (data) {
|
|
console.log(`VirtualContent: ${message}`, data);
|
|
} else {
|
|
console.log(`VirtualContent: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when the plugin is loaded.
|
|
*/
|
|
async onload() {
|
|
await this.loadSettings();
|
|
this.addSettingTab(new VirtualFooterSettingTab(this.app, this));
|
|
|
|
this.registerView(
|
|
VIRTUAL_CONTENT_VIEW_TYPE,
|
|
(leaf) => new VirtualContentView(leaf, this, VIRTUAL_CONTENT_VIEW_TYPE, 'Virtual Content', () => this.getLastSidebarContent())
|
|
);
|
|
|
|
this.registerDynamicViews();
|
|
|
|
this.addRibbonIcon('text-select', 'Open virtual content in sidebar', () => {
|
|
this.activateView(VIRTUAL_CONTENT_VIEW_TYPE);
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-virtual-content-sidebar',
|
|
name: 'Open virtual content in sidebar',
|
|
callback: () => {
|
|
this.activateView(VIRTUAL_CONTENT_VIEW_TYPE);
|
|
},
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-all-virtual-content-sidebar-tabs',
|
|
name: 'Open all virtual footer sidebar tabs',
|
|
callback: () => {
|
|
this.activateAllSidebarViews();
|
|
},
|
|
});
|
|
|
|
// Define event handlers
|
|
const handleViewUpdate = () => {
|
|
// Always trigger an update if the layout is ready.
|
|
// Used for file-open and layout-change.
|
|
if (this.initialLayoutReadyProcessed) {
|
|
this.handleActiveViewChange();
|
|
}
|
|
};
|
|
|
|
const handleFocusChange = () => {
|
|
// This is the "focus change" or "switching files" part, conditional on the setting.
|
|
// Used for active-leaf-change.
|
|
if (this.settings.refreshOnFileOpen && this.initialLayoutReadyProcessed) {
|
|
this.handleActiveViewChange();
|
|
}
|
|
};
|
|
|
|
// Register event listeners
|
|
this.registerEvent(
|
|
this.app.workspace.on('file-open', handleViewUpdate)
|
|
);
|
|
this.registerEvent(
|
|
this.app.workspace.on('layout-change', handleViewUpdate)
|
|
);
|
|
this.registerEvent(
|
|
this.app.workspace.on('active-leaf-change', handleFocusChange)
|
|
);
|
|
|
|
// Listen for metadata changes on the current file
|
|
this.registerEvent(
|
|
this.app.metadataCache.on('changed', (file) => {
|
|
// Only refresh if the metadata change setting is enabled
|
|
if (this.settings.refreshOnMetadataChange && this.initialLayoutReadyProcessed) {
|
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
// Only refresh if the changed file is the currently active one
|
|
if (activeView && activeView.file && file.path === activeView.file.path) {
|
|
this.handleActiveViewChange();
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
// Listen for hover events to detect when popovers are created
|
|
this.registerDomEvent(document, 'mouseover', (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
// Check if the target is a link that could trigger a popover
|
|
if (target.matches('a.internal-link, .internal-link a, [data-href]')) {
|
|
// Store the last hovered link for popover file path extraction
|
|
this.lastHoveredLink = target;
|
|
// Delay to allow popover to be created
|
|
setTimeout(() => {this.processPopoverViews();}, 100);
|
|
}
|
|
});
|
|
|
|
const handleSectionCollapseClick = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
const collapseIndicator = target.closest('.heading-collapse-indicator, .cm-fold-indicator, .collapse-indicator');
|
|
const sectionHeaderTarget = collapseIndicator ? this.getConfiguredSectionHeaderCollapseTarget(collapseIndicator) : null;
|
|
if (
|
|
collapseIndicator?.closest('.markdown-preview-view') &&
|
|
sectionHeaderTarget
|
|
) {
|
|
setTimeout(() => {
|
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (activeView) {
|
|
this.refreshSectionHeaderContent(activeView, true, sectionHeaderTarget);
|
|
}
|
|
}, 150);
|
|
}
|
|
};
|
|
document.addEventListener('click', handleSectionCollapseClick, true);
|
|
this.register(() => document.removeEventListener('click', handleSectionCollapseClick, true));
|
|
|
|
this.registerDomEvent(document, 'scroll', (event: Event) => {
|
|
const target = event.target as HTMLElement;
|
|
if (!target.closest?.('.markdown-preview-view')) {
|
|
return;
|
|
}
|
|
if (this.sectionHeaderScrollRefreshTimeout !== null) {
|
|
window.clearTimeout(this.sectionHeaderScrollRefreshTimeout);
|
|
}
|
|
this.sectionHeaderScrollRefreshTimeout = window.setTimeout(() => {
|
|
this.sectionHeaderScrollRefreshTimeout = null;
|
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (activeView) {
|
|
this.refreshSectionHeaderContent(activeView, false);
|
|
}
|
|
}, 250);
|
|
}, true);
|
|
|
|
// Listen for clicks to detect when popovers might switch to editing mode
|
|
this.registerDomEvent(document, 'click', (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
// Check if the click is within a popover
|
|
const popover = target.closest('.popover.hover-popover');
|
|
if (popover) {
|
|
//console.log("VirtualContent: Click detected in popover, checking for mode change");
|
|
// Delay to allow any mode changes to complete
|
|
setTimeout(() => {this.processPopoverViews();}, 150);
|
|
}
|
|
});
|
|
|
|
// Also listen for DOM mutations to catch dynamically created popovers
|
|
this.popoverObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === 'childList') {
|
|
mutation.addedNodes.forEach(node => {
|
|
if (node instanceof HTMLElement) {
|
|
// Check if a popover was added
|
|
if (node.classList.contains('popover') && node.classList.contains('hover-popover')) {
|
|
//console.log("VirtualContent: Popover created, processing views");
|
|
// Small delay to ensure the popover content is fully loaded
|
|
setTimeout(() => {this.processPopoverViews();}, 50);
|
|
}
|
|
// Also check for popovers added within other elements
|
|
const popovers = node.querySelectorAll('.popover.hover-popover');
|
|
if (popovers.length > 0) {
|
|
//console.log("VirtualContent: Popover(s) found in added content, processing views");
|
|
setTimeout(() => {this.processPopoverViews();}, 50);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// Listen for attribute changes that might indicate mode switching in popovers
|
|
if (mutation.type === 'attributes' && mutation.target instanceof HTMLElement) {
|
|
const target = mutation.target;
|
|
// Check if this is a popover that gained or lost the is-editing class
|
|
if (target.classList.contains('popover') && target.classList.contains('hover-popover')) {
|
|
if (mutation.attributeName === 'class') {
|
|
const hasEditingClass = target.classList.contains('is-editing');
|
|
//console.log(`VirtualContent: Popover mode changed, is-editing: ${hasEditingClass}`);
|
|
//setTimeout(() => {this.processPopoverViews();}, 100); // Slightly longer delay for mode changes
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Observe the entire document for popover creation
|
|
if (this.popoverObserver) {
|
|
this.popoverObserver.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
}
|
|
|
|
if (this.settings.enableCanvasRendering) {
|
|
// Observe the document for canvas node updates
|
|
this.canvasObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === 'childList') {
|
|
const addedOrRemovedNodes = [...mutation.addedNodes, ...mutation.removedNodes];
|
|
const hasCanvasNodeChange = addedOrRemovedNodes.some((node) => {
|
|
return node instanceof HTMLElement && (node.classList.contains('canvas-node') || node.querySelector('.canvas-node'));
|
|
});
|
|
if (hasCanvasNodeChange) {
|
|
this.queueCanvasEmbedRefresh();
|
|
}
|
|
}
|
|
if (mutation.type === 'attributes' && mutation.target instanceof HTMLElement) {
|
|
const target = mutation.target;
|
|
if (target.classList.contains('canvas-node')) {
|
|
this.queueCanvasEmbedRefresh();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (this.canvasObserver) {
|
|
this.canvasObserver.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ['class', 'data-path', 'data-href', 'data-src', 'src']
|
|
});
|
|
}
|
|
|
|
this.canvasInteractionHandler = (event: Event) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest?.('.canvas')) {
|
|
this.queueCanvasEmbedRefresh();
|
|
}
|
|
};
|
|
this.registerDomEvent(document, 'wheel', this.canvasInteractionHandler, true);
|
|
this.registerDomEvent(document, 'pointerup', this.canvasInteractionHandler, true);
|
|
}
|
|
|
|
// Initial processing for any currently active view, once layout is ready
|
|
this.app.workspace.onLayoutReady(() => {
|
|
if (!this.initialLayoutReadyProcessed) {
|
|
this.handleActiveViewChange(); // Process the initially open view
|
|
this.initialLayoutReadyProcessed = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called when the plugin is unloaded.
|
|
* Cleans up all injected content and observers.
|
|
*/
|
|
async onunload() {
|
|
this.popoverObserver?.disconnect();
|
|
this.canvasObserver?.disconnect();
|
|
this.app.workspace.detachLeavesOfType(VIRTUAL_CONTENT_VIEW_TYPE);
|
|
this.settings.rules.forEach((rule, index) => {
|
|
if (rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) {
|
|
this.app.workspace.detachLeavesOfType(this.getSeparateViewId(index));
|
|
}
|
|
});
|
|
this.clearAllViewsDynamicContent();
|
|
|
|
// Clean up any remaining DOM elements and components directly
|
|
document.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => {
|
|
const componentHolder = el as HTMLElementWithComponent;
|
|
if (componentHolder.component) {
|
|
componentHolder.component.unload();
|
|
}
|
|
el.remove();
|
|
});
|
|
|
|
// Remove custom 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));
|
|
|
|
// WeakMaps will be garbage collected, but explicit clearing is good practice if needed.
|
|
// Observers and pending injections are cleared per-view in `removeDynamicContentFromView`.
|
|
this.previewObservers = new WeakMap();
|
|
this.pendingPreviewInjections = new WeakMap();
|
|
this.embedObservers = new WeakMap();
|
|
this.embedRefreshTimeouts = new WeakMap();
|
|
if (this.canvasRefreshTimeout !== null) {
|
|
window.clearTimeout(this.canvasRefreshTimeout);
|
|
}
|
|
this.canvasRefreshTimeout = null;
|
|
this.canvasRefreshInProgress = false;
|
|
this.canvasInteractionHandler = null;
|
|
}
|
|
|
|
/**
|
|
* Handles changes to the active Markdown view, triggering content processing.
|
|
*/
|
|
private handleActiveViewChange = () => {
|
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (activeView?.file) {
|
|
void this._processView(activeView);
|
|
return;
|
|
}
|
|
void this.processNonMarkdownActiveFile();
|
|
}
|
|
|
|
/**
|
|
* Checks if a MarkdownView is displayed within a popover (hover preview).
|
|
* @param view The MarkdownView to check.
|
|
* @returns True if the view is in a popover, false otherwise.
|
|
*/
|
|
private isInPopover(view: MarkdownView): boolean {
|
|
// Check if the view's container element is within a popover
|
|
let element: HTMLElement | null = view.containerEl;
|
|
|
|
// Debug: Log the container element and its classes
|
|
//console.log("VirtualContent: Checking popover for view container:", view.containerEl.className);
|
|
|
|
while (element) {
|
|
// Check for popover classes
|
|
if (element.classList.contains('popover') && element.classList.contains('hover-popover')) {
|
|
//console.log("VirtualContent: Found popover via direct popover classes");
|
|
return true;
|
|
}
|
|
// Also check for markdown-embed class which indicates an embedded view (often in popovers)
|
|
if (element.classList.contains('markdown-embed')) {
|
|
// console.log("VirtualContent: Found markdown-embed, checking for parent popover");
|
|
// If it's a markdown-embed, check if it's inside a popover
|
|
let parent = element.parentElement;
|
|
while (parent) {
|
|
if (parent.classList.contains('popover') && parent.classList.contains('hover-popover')) {
|
|
// console.log("VirtualContent: Found popover via markdown-embed parent");
|
|
return true;
|
|
}
|
|
parent = parent.parentElement;
|
|
}
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
//console.log("VirtualContent: Not a popover view");
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Processes any popover views that might be open but haven't been processed yet.
|
|
*/
|
|
private processPopoverViews(): void {
|
|
// Find all popover elements in the DOM
|
|
const popovers = document.querySelectorAll('.popover.hover-popover');
|
|
|
|
popovers.forEach(popover => {
|
|
// Look for markdown views within each popover
|
|
const markdownEmbed = popover.querySelector('.markdown-embed');
|
|
if (markdownEmbed) {
|
|
//console.log("VirtualContent: Found markdown-embed in popover, processing directly");
|
|
// Process the popover content directly
|
|
this.processPopoverDirectly(popover as HTMLElement);
|
|
}
|
|
});
|
|
}
|
|
|
|
private queueEmbedRefresh(view: MarkdownView): void {
|
|
if (!this.settings.enableEmbedRendering) {
|
|
return;
|
|
}
|
|
const existingTimeout = this.embedRefreshTimeouts.get(view);
|
|
if (existingTimeout) {
|
|
window.clearTimeout(existingTimeout);
|
|
}
|
|
const currentPath = view.file?.path;
|
|
if (currentPath) {
|
|
const lastScan = this.embedLastScanByView.get(view);
|
|
if (lastScan && lastScan.filePath === currentPath && Date.now() - lastScan.time < 750) {
|
|
return;
|
|
}
|
|
}
|
|
const timeout = window.setTimeout(() => {
|
|
this.embedRefreshTimeouts.delete(view);
|
|
void this.processEmbedsInView(view);
|
|
}, 250);
|
|
this.embedRefreshTimeouts.set(view, timeout);
|
|
}
|
|
|
|
private queueCanvasEmbedRefresh(): void {
|
|
if (!this.settings.enableCanvasRendering) {
|
|
return;
|
|
}
|
|
if (!this.isCanvasActive()) {
|
|
return;
|
|
}
|
|
if (this.canvasRefreshTimeout !== null) {
|
|
return;
|
|
}
|
|
this.canvasRefreshTimeout = window.setTimeout(() => {
|
|
this.canvasRefreshTimeout = null;
|
|
if (this.canvasRefreshInProgress) {
|
|
return;
|
|
}
|
|
this.canvasRefreshInProgress = true;
|
|
void this.processCanvasEmbeds().then(() => {
|
|
this.canvasRefreshInProgress = false;
|
|
}, () => {
|
|
this.canvasRefreshInProgress = false;
|
|
});
|
|
}, 250);
|
|
}
|
|
|
|
private ensureEmbedObserver(view: MarkdownView): void {
|
|
if (!this.settings.enableEmbedRendering) {
|
|
return;
|
|
}
|
|
if (this.embedObservers.has(view)) {
|
|
return;
|
|
}
|
|
const observer = new MutationObserver((_mutations) => {
|
|
this.queueEmbedRefresh(view);
|
|
});
|
|
observer.observe(view.containerEl, { childList: true, subtree: true });
|
|
this.embedObservers.set(view, observer);
|
|
}
|
|
|
|
private isEmbedInCanvas(embed: HTMLElement): boolean {
|
|
return !!embed.closest('.canvas, .canvas-node, .canvas-node-container, .canvas-node-content');
|
|
}
|
|
|
|
private extractEmbedFilePath(embed: HTMLElement): string | null {
|
|
const canvasNode = embed.closest('.canvas-node');
|
|
if (canvasNode) {
|
|
const canvasPath = this.extractCanvasNodePath(canvasNode as HTMLElement);
|
|
if (canvasPath) {
|
|
this.logEmbedCanvasDebug('Canvas node path resolved', {
|
|
canvasPath
|
|
});
|
|
return canvasPath;
|
|
}
|
|
}
|
|
|
|
const embedContainer = embed.closest('.markdown-embed') as HTMLElement | null;
|
|
const scopedContainer = embedContainer || embed;
|
|
const embedLink = scopedContainer.querySelector('.markdown-embed-link a.internal-link[data-href]') as HTMLAnchorElement | null;
|
|
const embedLinkHref = embedLink?.dataset?.href;
|
|
const rawCandidates = [
|
|
scopedContainer.getAttribute('data-path'),
|
|
scopedContainer.getAttribute('data-src'),
|
|
scopedContainer.getAttribute('src'),
|
|
scopedContainer.getAttribute('data-href'),
|
|
scopedContainer.dataset?.path,
|
|
scopedContainer.dataset?.src,
|
|
scopedContainer.dataset?.href,
|
|
embedLinkHref,
|
|
];
|
|
const resolved = this.resolveFirstPathCandidate(rawCandidates);
|
|
if (!resolved) {
|
|
this.logEmbedCanvasDebug('Embed candidates not resolved', {
|
|
embedClass: scopedContainer.className,
|
|
candidates: rawCandidates.filter(Boolean)
|
|
});
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
private extractCanvasNodePath(canvasNode: HTMLElement): string | null {
|
|
const rawCandidates = [
|
|
canvasNode.getAttribute('data-path'),
|
|
canvasNode.getAttribute('data-href'),
|
|
canvasNode.getAttribute('data-src'),
|
|
canvasNode.getAttribute('data-file'),
|
|
canvasNode.getAttribute('data-file-path'),
|
|
canvasNode.getAttribute('data-filepath'),
|
|
canvasNode.dataset?.path,
|
|
canvasNode.dataset?.href,
|
|
canvasNode.dataset?.src,
|
|
canvasNode.dataset?.file,
|
|
canvasNode.dataset?.filePath,
|
|
canvasNode.dataset?.filepath,
|
|
];
|
|
|
|
const linkCandidate = canvasNode.querySelector('.canvas-node-title a.internal-link[data-href]') as HTMLAnchorElement | null;
|
|
if (linkCandidate?.dataset?.href) {
|
|
rawCandidates.push(linkCandidate.dataset.href);
|
|
}
|
|
|
|
const titleEl = canvasNode.querySelector('.canvas-node-title, .canvas-node-header, .canvas-node-label') as HTMLElement | null;
|
|
const titleText = titleEl?.textContent?.trim();
|
|
if (titleText) {
|
|
rawCandidates.push(titleText);
|
|
}
|
|
|
|
const resolved = this.resolveFirstPathCandidate(rawCandidates);
|
|
if (!resolved) {
|
|
const attrDump: Record<string, string> = {};
|
|
Array.from(canvasNode.attributes).forEach(attr => {
|
|
attrDump[attr.name] = attr.value;
|
|
});
|
|
this.logEmbedCanvasDebug('Canvas node path not resolved', {
|
|
canvasNodeClass: canvasNode.className,
|
|
attributes: attrDump,
|
|
candidates: rawCandidates.filter(Boolean)
|
|
});
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
private extractPathFromElement(element: HTMLElement): string | null {
|
|
const rawCandidates = [
|
|
element.getAttribute('data-path'),
|
|
element.getAttribute('data-href'),
|
|
element.getAttribute('data-src'),
|
|
element.getAttribute('src'),
|
|
element.dataset?.path,
|
|
element.dataset?.href,
|
|
element.dataset?.src,
|
|
];
|
|
|
|
return this.resolveFirstPathCandidate(rawCandidates);
|
|
}
|
|
|
|
private resolveFirstPathCandidate(rawCandidates: Array<string | null | undefined>): string | null {
|
|
for (const raw of rawCandidates) {
|
|
if (!raw) continue;
|
|
const cleaned = raw.split('#')[0].split('^')[0].trim();
|
|
if (!cleaned) continue;
|
|
const resolved = this.app.metadataCache.getFirstLinkpathDest(cleaned, '');
|
|
if (resolved) {
|
|
return resolved.path;
|
|
}
|
|
const abstractFile = this.app.vault.getAbstractFileByPath(cleaned);
|
|
if (abstractFile instanceof TFile) {
|
|
return abstractFile.path;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private findMetadataContainer(container: HTMLElement): HTMLElement | null {
|
|
return container.querySelector<HTMLElement>(`${SELECTOR_METADATA_CONTAINER}, .cm-obsidian-frontmatter`);
|
|
}
|
|
|
|
private async processEmbedContainer(
|
|
container: HTMLElement,
|
|
filePath: string,
|
|
allowRule: (rule: Rule) => boolean,
|
|
context: 'embed' | 'canvas'
|
|
): Promise<void> {
|
|
container.dataset.sourcePath = filePath;
|
|
const applicableRulesWithContent = await this._getApplicableRulesAndContent(filePath);
|
|
const filteredRules = applicableRulesWithContent.filter(({ rule }) => allowRule(rule));
|
|
await this.removeInjectedContentDOM(container);
|
|
|
|
if (filteredRules.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const headerContentGroups: { normal: string[], aboveProperties: string[] } = { normal: [], aboveProperties: [] };
|
|
const footerContentGroups: { normal: string[] } = { normal: [] };
|
|
const sectionHeaderRules: Array<{ rule: Rule; contentText: string; index: number }> = [];
|
|
const contentSeparator = "\n\n";
|
|
|
|
for (const { rule, contentText, index } of filteredRules) {
|
|
if (!contentText || contentText.trim() === "") continue;
|
|
|
|
if (rule.renderLocation === RenderLocation.Header) {
|
|
if (rule.renderAboveProperties) {
|
|
headerContentGroups.aboveProperties.push(contentText);
|
|
} else {
|
|
headerContentGroups.normal.push(contentText);
|
|
}
|
|
} else if (rule.renderLocation === RenderLocation.Footer) {
|
|
footerContentGroups.normal.push(contentText);
|
|
} else if (rule.renderLocation === RenderLocation.SectionHeader) {
|
|
sectionHeaderRules.push({ rule, contentText, index });
|
|
}
|
|
// Skip sidebar rules for embeds
|
|
}
|
|
|
|
if (headerContentGroups.normal.length > 0) {
|
|
const combinedContent = headerContentGroups.normal.join(contentSeparator);
|
|
await this.injectContentIntoPopoverSection(container, combinedContent, 'header', false, filePath, context);
|
|
}
|
|
|
|
if (headerContentGroups.aboveProperties.length > 0) {
|
|
const combinedContent = headerContentGroups.aboveProperties.join(contentSeparator);
|
|
await this.injectContentIntoPopoverSection(container, combinedContent, 'header', true, filePath, context);
|
|
}
|
|
|
|
if (footerContentGroups.normal.length > 0) {
|
|
const combinedContent = footerContentGroups.normal.join(contentSeparator);
|
|
await this.injectContentIntoPopoverSection(container, combinedContent, 'footer', false, filePath, context);
|
|
}
|
|
|
|
for (const { rule, contentText, index } of sectionHeaderRules) {
|
|
await this.injectContentIntoPopoverSectionHeader(container, contentText, rule, index, filePath);
|
|
}
|
|
}
|
|
|
|
private async processEmbedsInView(view: MarkdownView): Promise<void> {
|
|
if (!this.settings.enableEmbedRendering) {
|
|
return;
|
|
}
|
|
const embeds = Array.from(view.containerEl.querySelectorAll<HTMLElement>('.markdown-embed'));
|
|
this.logEmbedCanvasDebug('Embed scan in view', {
|
|
viewPath: view.file?.path,
|
|
embedCount: embeds.length
|
|
});
|
|
for (const embed of embeds) {
|
|
if (embed.closest('.popover.hover-popover')) {
|
|
continue;
|
|
}
|
|
if (this.isEmbedInCanvas(embed)) {
|
|
continue;
|
|
}
|
|
const filePath = this.extractEmbedFilePath(embed);
|
|
if (!filePath) {
|
|
this.logEmbedCanvasDebug('Embed path not resolved', {
|
|
embedClass: embed.className
|
|
});
|
|
continue;
|
|
}
|
|
await this.processEmbedContainer(embed, filePath, (rule) => rule.showInEmbed !== false, 'embed');
|
|
}
|
|
if (view.file?.path) {
|
|
this.embedLastScanByView.set(view, { filePath: view.file.path, time: Date.now() });
|
|
}
|
|
}
|
|
|
|
private getCanvasNodes(): HTMLElement[] {
|
|
const canvasRoot = document.querySelector('.canvas');
|
|
const scopedNodes = canvasRoot ? Array.from(canvasRoot.querySelectorAll<HTMLElement>('.canvas-node')) : [];
|
|
if (scopedNodes.length > 0) {
|
|
return scopedNodes;
|
|
}
|
|
return Array.from(document.querySelectorAll<HTMLElement>('.canvas-node'));
|
|
}
|
|
|
|
private isCanvasActive(): boolean {
|
|
return !!document.querySelector('.workspace-leaf.mod-active .canvas, .workspace-leaf.mod-active .canvas-wrapper');
|
|
}
|
|
|
|
private async processCanvasEmbeds(): Promise<void> {
|
|
if (!this.settings.enableCanvasRendering) {
|
|
return;
|
|
}
|
|
if (!this.isCanvasActive()) {
|
|
return;
|
|
}
|
|
const canvasRoot = document.querySelector('.canvas');
|
|
if (!canvasRoot) {
|
|
return;
|
|
}
|
|
const canvasNodes = this.getCanvasNodes();
|
|
this.logEmbedCanvasDebug('Canvas embed scan', {
|
|
nodeCount: canvasNodes.length
|
|
});
|
|
if (canvasNodes.length === 0) {
|
|
return;
|
|
}
|
|
for (const node of canvasNodes) {
|
|
const embedContainer = node.querySelector<HTMLElement>('.canvas-node-content.markdown-embed, .canvas-node-content .markdown-embed');
|
|
if (!embedContainer) {
|
|
continue;
|
|
}
|
|
if (node.classList.contains('is-editing') || embedContainer.querySelector('.cm-editor')) {
|
|
await this.removeInjectedContentDOM(embedContainer);
|
|
continue;
|
|
}
|
|
const filePath = this.extractCanvasNodePath(node);
|
|
if (!filePath) {
|
|
await this.removeInjectedContentDOM(embedContainer);
|
|
this.logEmbedCanvasDebug('Canvas embed path not resolved', {
|
|
embedClass: embedContainer.className
|
|
});
|
|
continue;
|
|
}
|
|
this.logEmbedCanvasDebug('Canvas embed resolved', {
|
|
filePath
|
|
});
|
|
await this.processEmbedContainer(embedContainer, filePath, (rule) => rule.showInCanvas !== false, 'canvas');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process popover content directly when we can't find the MarkdownView
|
|
*/
|
|
private processPopoverDirectly(popover: HTMLElement): void {
|
|
// console.log("VirtualContent: Processing popover directly");
|
|
|
|
// Try to extract the file path from the popover
|
|
const markdownEmbed = popover.querySelector('.markdown-embed');
|
|
if (!markdownEmbed) {
|
|
//console.log("VirtualContent: No markdown-embed found in popover");
|
|
return;
|
|
}
|
|
|
|
let filePath: string | null = null;
|
|
|
|
// Method 1: Get the title from inline-title and resolve to file path
|
|
const inlineTitle = popover.querySelector('.inline-title');
|
|
if (inlineTitle) {
|
|
const title = inlineTitle.textContent?.trim();
|
|
//console.log("VirtualContent: Found inline-title:", title);
|
|
|
|
if (title) {
|
|
// Try to resolve the title to a file path using Obsidian's API
|
|
const file = this.app.metadataCache.getFirstLinkpathDest(title, '');
|
|
if (file) {
|
|
filePath = file.path;
|
|
//console.log("VirtualContent: Resolved title to file path:", filePath);
|
|
} else {
|
|
// If direct resolution fails, try with .md extension
|
|
const fileWithExt = this.app.metadataCache.getFirstLinkpathDest(title + '.md', '');
|
|
if (fileWithExt) {
|
|
filePath = fileWithExt.path;
|
|
//console.log("VirtualContent: Resolved title with .md extension to file path:", filePath);
|
|
} else {
|
|
//console.log("VirtualContent: Could not resolve title to file path");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//console.log("VirtualContent: Final extracted file path for direct processing:", filePath);
|
|
|
|
if (filePath) {
|
|
// Remove any hash fragments or block references
|
|
const cleanPath = filePath.split('#')[0].split('^')[0];
|
|
//console.log("VirtualContent: Cleaned file path:", cleanPath);
|
|
// Process the popover content directly
|
|
this.injectContentIntoPopover(popover, cleanPath);
|
|
} else {
|
|
// console.log("VirtualContent: Could not determine file path for popover");
|
|
// // Log the DOM structure for debugging
|
|
// console.log("VirtualContent: Popover DOM structure:", popover.innerHTML.substring(0, 1000));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Directly inject virtual content into a popover
|
|
*/
|
|
private async injectContentIntoPopover(popover: HTMLElement, filePath: string): Promise<void> {
|
|
//console.log("VirtualContent: Directly injecting content into popover for:", filePath);
|
|
|
|
try {
|
|
const applicableRulesWithContent = await this._getApplicableRulesAndContent(filePath);
|
|
|
|
// Filter rules based on popover visibility setting
|
|
const filteredRules = applicableRulesWithContent.filter(({ rule }) => {
|
|
return rule.showInPopover !== false; // Show by default unless explicitly disabled
|
|
});
|
|
|
|
if (filteredRules.length === 0) {
|
|
//console.log("VirtualContent: No applicable rules for popover");
|
|
return;
|
|
}
|
|
|
|
// Find the markdown embed container
|
|
const markdownEmbed = popover.querySelector('.markdown-embed');
|
|
if (!markdownEmbed) return;
|
|
|
|
// Group content by render location
|
|
const headerContentGroups: { normal: string[], aboveProperties: string[] } = { normal: [], aboveProperties: [] };
|
|
const footerContentGroups: { normal: string[], aboveBacklinks: string[] } = { normal: [], aboveBacklinks: [] };
|
|
const sectionHeaderRules: Array<{ rule: Rule; contentText: string; index: number }> = [];
|
|
const contentSeparator = "\n\n";
|
|
|
|
for (const { rule, contentText, index } of filteredRules) {
|
|
if (!contentText || contentText.trim() === "") continue;
|
|
|
|
if (rule.renderLocation === RenderLocation.Header) {
|
|
if (rule.renderAboveProperties) {
|
|
headerContentGroups.aboveProperties.push(contentText);
|
|
} else {
|
|
headerContentGroups.normal.push(contentText);
|
|
}
|
|
} else if (rule.renderLocation === RenderLocation.Footer) {
|
|
// For popovers, treat all footer content the same regardless of renderAboveBacklinks setting
|
|
// since backlinks don't exist in popovers
|
|
footerContentGroups.normal.push(contentText);
|
|
} else if (rule.renderLocation === RenderLocation.SectionHeader) {
|
|
sectionHeaderRules.push({ rule, contentText, index });
|
|
}
|
|
// Skip sidebar rules for popovers
|
|
}
|
|
|
|
// Inject header content
|
|
if (headerContentGroups.normal.length > 0) {
|
|
const combinedContent = headerContentGroups.normal.join(contentSeparator);
|
|
await this.injectContentIntoPopoverSection(markdownEmbed as HTMLElement, combinedContent, 'header', false, filePath);
|
|
}
|
|
|
|
if (headerContentGroups.aboveProperties.length > 0) {
|
|
const combinedContent = headerContentGroups.aboveProperties.join(contentSeparator);
|
|
await this.injectContentIntoPopoverSection(markdownEmbed as HTMLElement, combinedContent, 'header', true, filePath);
|
|
}
|
|
|
|
// Inject footer content
|
|
if (footerContentGroups.normal.length > 0) {
|
|
const combinedContent = footerContentGroups.normal.join(contentSeparator);
|
|
await this.injectContentIntoPopoverSection(markdownEmbed as HTMLElement, combinedContent, 'footer', false, filePath);
|
|
}
|
|
|
|
for (const { rule, contentText, index } of sectionHeaderRules) {
|
|
await this.injectContentIntoPopoverSectionHeader(markdownEmbed as HTMLElement, contentText, rule, index, filePath);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("VirtualContent: Error processing popover directly:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inject content into a specific section of a popover
|
|
*/
|
|
private async injectContentIntoPopoverSection(
|
|
container: HTMLElement,
|
|
content: string,
|
|
location: 'header' | 'footer',
|
|
special: boolean,
|
|
filePath: string,
|
|
context?: 'embed' | 'canvas'
|
|
): Promise<void> {
|
|
const isHeader = location === 'header';
|
|
const cssClass = isHeader ? CSS_HEADER_GROUP_ELEMENT : CSS_FOOTER_GROUP_ELEMENT;
|
|
const specialClass = isHeader ? 'virtual-footer-above-properties' : 'virtual-footer-above-backlinks';
|
|
|
|
// Create new content container
|
|
const groupDiv = document.createElement('div') as HTMLElementWithComponent;
|
|
groupDiv.className = `${CSS_DYNAMIC_CONTENT_ELEMENT} ${cssClass}`;
|
|
groupDiv.dataset.sourcePath = filePath;
|
|
if (special) {
|
|
groupDiv.classList.add(specialClass);
|
|
}
|
|
if (context === 'embed') {
|
|
groupDiv.classList.add('virtual-footer-embedded-content');
|
|
}
|
|
if (context === 'canvas') {
|
|
groupDiv.classList.add('virtual-footer-canvas-content');
|
|
}
|
|
|
|
// Add additional CSS classes for consistency with main view injection
|
|
if (isHeader) {
|
|
groupDiv.classList.add(CSS_HEADER_RENDERED_CONTENT);
|
|
} else {
|
|
groupDiv.classList.add(CSS_FOOTER_RENDERED_CONTENT);
|
|
if (special) {
|
|
groupDiv.classList.add(CSS_ABOVE_BACKLINKS);
|
|
}
|
|
}
|
|
|
|
// Create component for lifecycle management
|
|
const component = new Component();
|
|
component.load();
|
|
groupDiv.component = component;
|
|
|
|
try {
|
|
// Render the content
|
|
await MarkdownRenderer.render(this.app, content, groupDiv, filePath, component);
|
|
this.attachInternalLinkHandlers(groupDiv, filePath, component);
|
|
|
|
// Use the same logic as main view injection - find target parent using standard selectors
|
|
let targetParent: HTMLElement | null = null;
|
|
|
|
// First, detect if we're in editing mode or preview mode
|
|
// Check if the popover container has the is-editing class
|
|
const popoverContainer = container.closest('.popover.hover-popover');
|
|
const isEditingMode = popoverContainer?.classList.contains('is-editing') ||
|
|
container.querySelector(SELECTOR_EDITOR_SIZER) !== null;
|
|
//console.log(`VirtualContent: Popover is in ${isEditingMode ? 'editing' : 'preview'} mode`);
|
|
|
|
if (isHeader) {
|
|
if (special) {
|
|
// Try to find metadata container first (same as main view logic)
|
|
targetParent = this.findMetadataContainer(container);
|
|
}
|
|
// If no metadata container or special is false, use appropriate header area
|
|
if (!targetParent) {
|
|
if (isEditingMode) {
|
|
// In editing mode, we need to find the content container and insert before it
|
|
const cmContentContainer = container.querySelector<HTMLElement>(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER);
|
|
if (cmContentContainer?.parentElement) {
|
|
// We'll handle the insertion differently for editing mode headers
|
|
targetParent = cmContentContainer.parentElement;
|
|
}
|
|
} else {
|
|
// In preview mode, use regular header area
|
|
targetParent = container.querySelector<HTMLElement>(SELECTOR_PREVIEW_HEADER_AREA);
|
|
if (!targetParent) {
|
|
targetParent = container.querySelector<HTMLElement>('.markdown-preview-view, .markdown-embed-content');
|
|
}
|
|
}
|
|
}
|
|
} else { // Footer
|
|
if (special) {
|
|
// Try to find embedded backlinks first (same as main view logic)
|
|
targetParent = container.querySelector<HTMLElement>(SELECTOR_EMBEDDED_BACKLINKS);
|
|
}
|
|
// If no backlinks or special is false, use appropriate footer area
|
|
if (!targetParent) {
|
|
if (isEditingMode) {
|
|
// In editing mode, use editor sizer
|
|
targetParent = container.querySelector<HTMLElement>(SELECTOR_EDITOR_SIZER);
|
|
} else {
|
|
// In preview mode, try standard footer area first
|
|
targetParent = container.querySelector<HTMLElement>(SELECTOR_PREVIEW_FOOTER_AREA);
|
|
// Fallback for popovers: use markdown-preview-sizer if standard footer selectors don't exist
|
|
if (!targetParent) {
|
|
targetParent = container.querySelector<HTMLElement>('.markdown-preview-sizer.markdown-preview-section');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (targetParent) {
|
|
// Remove existing content of this type (same cleanup logic as main view)
|
|
if (isHeader && special) {
|
|
// Remove existing header content above properties
|
|
container.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else if (isHeader && !special) {
|
|
// Remove existing normal header content
|
|
targetParent.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}:not(.virtual-footer-above-properties)`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else if (!isHeader && special) {
|
|
// Remove existing footer content above backlinks
|
|
container.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else if (!isHeader && !special) {
|
|
// Remove existing normal footer content
|
|
targetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}:not(.virtual-footer-above-backlinks)`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
}
|
|
|
|
// Insert using mode-specific logic
|
|
if (isHeader && !special) {
|
|
if (isEditingMode && targetParent.querySelector(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER)) {
|
|
// For editing mode headers, insert before the content container
|
|
const cmContentContainer = targetParent.querySelector<HTMLElement>(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER);
|
|
if (cmContentContainer) {
|
|
targetParent.insertBefore(groupDiv, cmContentContainer);
|
|
} else {
|
|
targetParent.appendChild(groupDiv);
|
|
}
|
|
} else {
|
|
// For preview mode headers, append to header area
|
|
targetParent.appendChild(groupDiv);
|
|
}
|
|
} else if (!isHeader && !special) {
|
|
// For footer content
|
|
if (isEditingMode) {
|
|
// In editing mode, append to editor sizer
|
|
targetParent.appendChild(groupDiv);
|
|
} else {
|
|
// In preview mode, check if we're using the popover fallback selector
|
|
if (targetParent.matches('.markdown-preview-sizer.markdown-preview-section')) {
|
|
// Insert after the markdown-preview-sizer, not inside it
|
|
targetParent.parentElement?.insertBefore(groupDiv, targetParent.nextSibling);
|
|
} else {
|
|
// For regular footer areas, append inside
|
|
targetParent.appendChild(groupDiv);
|
|
}
|
|
}
|
|
} else {
|
|
// Insert before properties or backlinks (same as main view)
|
|
targetParent.parentElement?.insertBefore(groupDiv, targetParent);
|
|
}
|
|
|
|
//console.log(`VirtualContent: Successfully injected ${location} content into popover using standard selectors`);
|
|
} else {
|
|
//console.log(`VirtualContent: Target parent not found for ${location} injection in popover, falling back to container`);
|
|
// Fallback to simple container injection if selectors don't match
|
|
if (isHeader) {
|
|
container.insertBefore(groupDiv, container.firstChild);
|
|
} else {
|
|
container.appendChild(groupDiv);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("VirtualContent: Error rendering content for popover:", error);
|
|
component.unload();
|
|
}
|
|
}
|
|
|
|
private async injectContentIntoPopoverSectionHeader(
|
|
container: HTMLElement,
|
|
content: string,
|
|
rule: Rule,
|
|
ruleIndex: number,
|
|
filePath: string
|
|
): Promise<void> {
|
|
if (!content || content.trim() === "" || !rule.sectionHeaderText?.trim()) {
|
|
return;
|
|
}
|
|
|
|
const component = new Component();
|
|
component.load();
|
|
|
|
const groupDiv = document.createElement('div') as HTMLElementWithComponent;
|
|
groupDiv.className = `${CSS_DYNAMIC_CONTENT_ELEMENT} ${CSS_SECTION_HEADER_GROUP_ELEMENT}`;
|
|
this.setSectionHeaderDataset(groupDiv, rule, ruleIndex);
|
|
groupDiv.dataset.sourcePath = filePath;
|
|
groupDiv.component = component;
|
|
|
|
try {
|
|
await MarkdownRenderer.render(this.app, content, groupDiv, filePath, component);
|
|
this.attachInternalLinkHandlers(groupDiv, filePath, component);
|
|
|
|
const popoverContainer = container.closest('.popover.hover-popover');
|
|
const isEditingMode = popoverContainer?.classList.contains('is-editing') ||
|
|
container.querySelector(SELECTOR_EDITOR_SIZER) !== null;
|
|
const level = this.getSectionHeaderLevelNumber(rule);
|
|
const placement = rule.sectionHeaderPlacement || 'top';
|
|
const target = isEditingMode
|
|
? this.findEditorSectionTarget(container, rule)
|
|
: this.findPreviewSectionTarget(container, rule);
|
|
|
|
if (!target) {
|
|
component.unload();
|
|
return;
|
|
}
|
|
|
|
this.removeSectionHeaderContent(container, ruleIndex);
|
|
if (this.isHeadingCollapsed(target)) {
|
|
component.unload();
|
|
return;
|
|
}
|
|
|
|
if (isEditingMode) {
|
|
if (placement === 'top') {
|
|
target.parentElement?.insertBefore(groupDiv, target.nextSibling);
|
|
} else {
|
|
const endNode = this.getEditorSectionEnd(target, level);
|
|
if (endNode) {
|
|
endNode.parentElement?.insertBefore(groupDiv, endNode);
|
|
} else {
|
|
target.parentElement?.appendChild(groupDiv);
|
|
}
|
|
}
|
|
} else {
|
|
const anchor = target.parentElement || target;
|
|
if (placement === 'top') {
|
|
anchor.parentElement?.insertBefore(groupDiv, anchor.nextSibling);
|
|
} else {
|
|
const endNode = this.getPreviewSectionEnd(target, level);
|
|
if (endNode) {
|
|
endNode.parentElement?.insertBefore(groupDiv, endNode);
|
|
} else {
|
|
anchor.parentElement?.appendChild(groupDiv);
|
|
}
|
|
}
|
|
}
|
|
this.updateSectionHeaderVisibility(container, isEditingMode);
|
|
} catch (error) {
|
|
console.error("VirtualContent: Error rendering section header content for popover:", error);
|
|
component.unload();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes a given Markdown view to inject or update dynamic content.
|
|
* @param view The MarkdownView to process.
|
|
*/
|
|
private async _processView(view: MarkdownView | null): Promise<void> {
|
|
if (!view || !view.file) {
|
|
// If 'refresh on focus' is off, we clear the sidebar when focus is lost from a markdown file.
|
|
// If it's on, we only clear the sidebar if the last markdown file has been closed,
|
|
// preserving the content when switching to non-markdown views.
|
|
if (!this.settings.refreshOnFileOpen || this.app.workspace.getLeavesOfType('markdown').length === 0) {
|
|
this.lastSidebarContent = null;
|
|
this.lastSeparateTabContents.clear();
|
|
this.updateAllSidebarViews();
|
|
}
|
|
return; // No view or file to process
|
|
}
|
|
|
|
// Check if this is a popover view
|
|
const isPopoverView = this.isInPopover(view);
|
|
|
|
await this.removeDynamicContentFromView(view, true); // Clear existing content first, preserving section content until its target is found
|
|
this.removeStaleSectionHeaderContent(view);
|
|
const applicableRulesWithContent = await this._getApplicableRulesAndContent(view.file.path);
|
|
|
|
// Filter rules based on popover visibility setting
|
|
const filteredRules = applicableRulesWithContent.filter(({ rule }) => {
|
|
if (isPopoverView && rule.showInPopover === false) {
|
|
return false; // Skip this rule in popover views
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const viewState = view.getState();
|
|
let combinedHeaderText = "";
|
|
let combinedFooterText = "";
|
|
let combinedSidebarText = "";
|
|
let hasFooterRule = false;
|
|
const contentSeparator = "\n\n"; // Separator between content from multiple rules
|
|
this.lastSeparateTabContents.clear();
|
|
|
|
// Combine content from all applicable rules, grouping by render location and positioning
|
|
const headerContentGroups: { normal: string[], aboveProperties: string[] } = { normal: [], aboveProperties: [] };
|
|
const footerContentGroups: { normal: string[], aboveBacklinks: string[] } = { normal: [], aboveBacklinks: [] };
|
|
const sectionHeaderRules: Array<{ rule: Rule; contentText: string; index: number }> = [];
|
|
|
|
for (const { rule, contentText, index } of filteredRules) {
|
|
if (!contentText || contentText.trim() === "") continue; // Skip empty content
|
|
|
|
if (rule.renderLocation === RenderLocation.Header) {
|
|
if (rule.renderAboveProperties) {
|
|
headerContentGroups.aboveProperties.push(contentText);
|
|
} else {
|
|
headerContentGroups.normal.push(contentText);
|
|
}
|
|
} else if (rule.renderLocation === RenderLocation.Footer) {
|
|
if (rule.renderAboveBacklinks) {
|
|
footerContentGroups.aboveBacklinks.push(contentText);
|
|
} else {
|
|
footerContentGroups.normal.push(contentText);
|
|
}
|
|
hasFooterRule = true;
|
|
} else if (rule.renderLocation === RenderLocation.Sidebar) {
|
|
if (rule.showInSeparateTab) {
|
|
const viewId = this.getSeparateViewId(index);
|
|
const existingContent = this.lastSeparateTabContents.get(viewId)?.content || "";
|
|
this.lastSeparateTabContents.set(viewId, {
|
|
content: (existingContent ? existingContent + contentSeparator : "") + contentText,
|
|
sourcePath: view.file.path
|
|
});
|
|
} else {
|
|
combinedSidebarText += (combinedSidebarText ? contentSeparator : "") + contentText;
|
|
}
|
|
} else if (rule.renderLocation === RenderLocation.SectionHeader) {
|
|
sectionHeaderRules.push({ rule, contentText, index });
|
|
}
|
|
}
|
|
|
|
// Store sidebar content and update the view
|
|
this.lastSidebarContent = { content: combinedSidebarText, sourcePath: view.file.path };
|
|
this.updateAllSidebarViews();
|
|
|
|
// Determine if we should render based on view mode and settings
|
|
const isLivePreview = viewState.mode === 'source' && !viewState.source;
|
|
const isSourceMode = viewState.mode === 'source' && viewState.source;
|
|
const isReadingMode = viewState.mode === 'preview';
|
|
|
|
const shouldRenderInSource = isSourceMode && this.settings.renderInSourceMode;
|
|
const shouldRenderInLivePreview = isLivePreview;
|
|
const shouldRenderInReading = isReadingMode;
|
|
|
|
// Apply specific styles for Live Preview footers if needed
|
|
if ((shouldRenderInLivePreview || shouldRenderInSource) && hasFooterRule) {
|
|
this.applyLivePreviewFooterStyles(view);
|
|
}
|
|
|
|
if (hasFooterRule && (shouldRenderInReading || shouldRenderInLivePreview || shouldRenderInSource)) {
|
|
this.applyFooterBottomPadding(view);
|
|
} else {
|
|
this.removeFooterBottomPadding(view);
|
|
}
|
|
|
|
let pendingHeaderDiv: HTMLElementWithComponent | null = null;
|
|
let pendingFooterDiv: HTMLElementWithComponent | null = null;
|
|
let pendingHeaderAbovePropertiesDiv: HTMLElementWithComponent | null = null;
|
|
let pendingFooterAboveBacklinksDiv: HTMLElementWithComponent | null = null;
|
|
|
|
// Render and inject content based on view mode, handling each positioning group separately
|
|
if (shouldRenderInReading || shouldRenderInLivePreview || shouldRenderInSource) {
|
|
// Handle normal header content
|
|
if (headerContentGroups.normal.length > 0) {
|
|
const combinedContent = headerContentGroups.normal.join(contentSeparator);
|
|
const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Header, false);
|
|
if (result && shouldRenderInReading) {
|
|
pendingHeaderDiv = result;
|
|
}
|
|
}
|
|
|
|
// Handle header content above properties
|
|
if (headerContentGroups.aboveProperties.length > 0) {
|
|
const combinedContent = headerContentGroups.aboveProperties.join(contentSeparator);
|
|
const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Header, true);
|
|
if (result && shouldRenderInReading) {
|
|
pendingHeaderAbovePropertiesDiv = result;
|
|
}
|
|
}
|
|
|
|
// Handle normal footer content
|
|
if (footerContentGroups.normal.length > 0) {
|
|
const combinedContent = footerContentGroups.normal.join(contentSeparator);
|
|
const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Footer, false, false);
|
|
if (result && shouldRenderInReading) {
|
|
pendingFooterDiv = result;
|
|
}
|
|
}
|
|
|
|
// Handle footer content above backlinks
|
|
if (footerContentGroups.aboveBacklinks.length > 0) {
|
|
const combinedContent = footerContentGroups.aboveBacklinks.join(contentSeparator);
|
|
const result = await this.renderAndInjectGroupedContent(view, combinedContent, RenderLocation.Footer, false, true);
|
|
if (result && shouldRenderInReading) {
|
|
pendingFooterAboveBacklinksDiv = result;
|
|
}
|
|
}
|
|
|
|
for (const { rule, contentText, index } of sectionHeaderRules) {
|
|
await this.renderAndInjectSectionHeaderContent(view, contentText, rule, index, true);
|
|
}
|
|
|
|
}
|
|
|
|
// If any content is pending for preview mode, set up an observer
|
|
if (pendingHeaderDiv || pendingFooterDiv || pendingHeaderAbovePropertiesDiv || pendingFooterAboveBacklinksDiv) {
|
|
this.pendingPreviewInjections.set(view, {
|
|
headerDiv: pendingHeaderDiv || undefined,
|
|
footerDiv: pendingFooterDiv || undefined,
|
|
headerAbovePropertiesDiv: pendingHeaderAbovePropertiesDiv || undefined,
|
|
footerAboveBacklinksDiv: pendingFooterAboveBacklinksDiv || undefined,
|
|
filePath: view.file.path,
|
|
});
|
|
this.ensurePreviewObserver(view);
|
|
}
|
|
|
|
this.ensureEmbedObserver(view);
|
|
await this.processEmbedsInView(view);
|
|
this.queueCanvasEmbedRefresh();
|
|
}
|
|
|
|
private normalizeSectionHeaderText(text: string): string {
|
|
return text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
}
|
|
|
|
private getSectionHeaderLevelNumber(rule: Rule): number {
|
|
const match = (rule.sectionHeaderLevel || 'h2').match(/^h([1-6])$/);
|
|
return match ? Number(match[1]) : 2;
|
|
}
|
|
|
|
private findPreviewSectionTarget(container: HTMLElement, rule: Rule): HTMLElement | null {
|
|
const targetText = this.normalizeSectionHeaderText(rule.sectionHeaderText || '');
|
|
if (!targetText) return null;
|
|
|
|
const selector = rule.sectionHeaderLevel || 'h2';
|
|
const headings = Array.from(container.querySelectorAll<HTMLElement>(selector));
|
|
return headings.find((heading) => this.normalizeSectionHeaderText(heading.textContent || '') === targetText) || null;
|
|
}
|
|
|
|
private findEditorSectionTarget(container: HTMLElement, rule: Rule): HTMLElement | null {
|
|
const targetText = this.normalizeSectionHeaderText(rule.sectionHeaderText || '');
|
|
if (!targetText) return null;
|
|
|
|
const level = this.getSectionHeaderLevelNumber(rule);
|
|
const headings = Array.from(container.querySelectorAll<HTMLElement>(`.cm-line.HyperMD-header-${level}`));
|
|
return headings.find((heading) => {
|
|
const text = (heading.textContent || '').replace(/^#+\s*/, '');
|
|
return this.normalizeSectionHeaderText(text) === targetText;
|
|
}) || null;
|
|
}
|
|
|
|
private getPreviewSectionEnd(heading: HTMLElement, level: number): Node | null {
|
|
let node: Node | null = heading.parentElement?.nextSibling || heading.nextSibling;
|
|
while (node) {
|
|
if (node instanceof HTMLElement) {
|
|
const nextHeading = node.matches('h1,h2,h3,h4,h5,h6')
|
|
? node
|
|
: node.querySelector<HTMLElement>('h1,h2,h3,h4,h5,h6');
|
|
if (nextHeading) {
|
|
const nextLevel = Number(nextHeading.tagName.substring(1));
|
|
if (nextLevel <= level) {
|
|
return node;
|
|
}
|
|
}
|
|
}
|
|
node = node.nextSibling;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private getEditorSectionEnd(headingLine: HTMLElement, level: number): Node | null {
|
|
let node: Node | null = headingLine.nextSibling;
|
|
while (node) {
|
|
if (node instanceof HTMLElement) {
|
|
for (let currentLevel = 1; currentLevel <= level; currentLevel++) {
|
|
if (node.classList.contains(`HyperMD-header-${currentLevel}`)) {
|
|
return node;
|
|
}
|
|
}
|
|
}
|
|
node = node.nextSibling;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private removeSectionHeaderContent(container: HTMLElement, ruleIndex: number): void {
|
|
container.querySelectorAll(`.${CSS_SECTION_HEADER_GROUP_ELEMENT}[data-rule-index="${ruleIndex}"]`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
}
|
|
|
|
private isHeadingCollapsed(heading: HTMLElement): boolean {
|
|
let element: HTMLElement | null = heading;
|
|
let depth = 0;
|
|
while (element && depth < 4) {
|
|
const className = element.className.toString().toLowerCase();
|
|
if (
|
|
element.getAttribute('aria-expanded') === 'false' ||
|
|
className.includes('is-collapsed') ||
|
|
className.includes('is-folded') ||
|
|
className.includes('folded')
|
|
) {
|
|
return true;
|
|
}
|
|
element = element.parentElement;
|
|
depth++;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private setSectionHeaderDataset(groupDiv: HTMLElement, rule: Rule, ruleIndex: number): void {
|
|
groupDiv.dataset.ruleIndex = String(ruleIndex);
|
|
groupDiv.dataset.sectionHeaderText = rule.sectionHeaderText || '';
|
|
groupDiv.dataset.sectionHeaderLevel = rule.sectionHeaderLevel || 'h2';
|
|
groupDiv.dataset.sectionHeaderPlacement = rule.sectionHeaderPlacement || 'top';
|
|
}
|
|
|
|
private removeStaleSectionHeaderContent(view: MarkdownView): void {
|
|
const sourcePath = view.file?.path || '';
|
|
view.containerEl.querySelectorAll<HTMLElement>(`.${CSS_SECTION_HEADER_GROUP_ELEMENT}`).forEach(el => {
|
|
if (el.dataset.sourcePath !== sourcePath) {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateSectionHeaderVisibility(container: HTMLElement, isEditingMode: boolean): void {
|
|
container.querySelectorAll<HTMLElement>(`.${CSS_SECTION_HEADER_GROUP_ELEMENT}`).forEach(groupDiv => {
|
|
const rule: Rule = {
|
|
type: RuleType.Folder,
|
|
contentSource: ContentSource.Text,
|
|
footerText: '',
|
|
renderLocation: RenderLocation.SectionHeader,
|
|
sectionHeaderText: groupDiv.dataset.sectionHeaderText || '',
|
|
sectionHeaderLevel: groupDiv.dataset.sectionHeaderLevel || 'h2',
|
|
sectionHeaderPlacement: groupDiv.dataset.sectionHeaderPlacement === 'bottom' ? 'bottom' : 'top',
|
|
};
|
|
const heading = isEditingMode
|
|
? this.findEditorSectionTarget(container, rule)
|
|
: this.findPreviewSectionTarget(container, rule);
|
|
groupDiv.toggleAttribute('hidden', heading ? this.isHeadingCollapsed(heading) : false);
|
|
});
|
|
}
|
|
|
|
private isConfiguredSectionHeaderElement(heading: HTMLElement): boolean {
|
|
const headingText = this.normalizeSectionHeaderText(heading.textContent || '');
|
|
if (!headingText) {
|
|
return false;
|
|
}
|
|
|
|
const headingLevel = heading.tagName.match(/^H([1-6])$/)
|
|
? heading.tagName.toLowerCase()
|
|
: Array.from(heading.classList)
|
|
.map(className => className.match(/^HyperMD-header-([1-6])$/)?.[1])
|
|
.find(Boolean);
|
|
const normalizedHeadingLevel = headingLevel && headingLevel.length === 1 ? `h${headingLevel}` : headingLevel;
|
|
|
|
return this.settings.rules.some(rule =>
|
|
rule.enabled &&
|
|
rule.renderLocation === RenderLocation.SectionHeader &&
|
|
this.normalizeSectionHeaderText(rule.sectionHeaderText || '') === headingText &&
|
|
(rule.sectionHeaderLevel || 'h2') === normalizedHeadingLevel
|
|
);
|
|
}
|
|
|
|
private getConfiguredSectionHeaderCollapseTarget(collapseIndicator: Element): { text: string; level: string } | null {
|
|
const previewHeading = collapseIndicator.closest('[class^="el-h"], [class*=" el-h"]')?.querySelector<HTMLElement>('h1,h2,h3,h4,h5,h6');
|
|
if (previewHeading && this.isConfiguredSectionHeaderElement(previewHeading)) {
|
|
return {
|
|
text: this.normalizeSectionHeaderText(previewHeading.textContent || ''),
|
|
level: previewHeading.tagName.toLowerCase(),
|
|
};
|
|
}
|
|
|
|
const editorHeading = collapseIndicator.closest<HTMLElement>('.cm-line[class*="HyperMD-header-"]');
|
|
if (!editorHeading || !this.isConfiguredSectionHeaderElement(editorHeading)) {
|
|
return null;
|
|
}
|
|
|
|
const level = Array.from(editorHeading.classList)
|
|
.map(className => className.match(/^HyperMD-header-([1-6])$/)?.[1])
|
|
.find(Boolean);
|
|
return level ? {
|
|
text: this.normalizeSectionHeaderText(editorHeading.textContent || ''),
|
|
level: `h${level}`,
|
|
} : null;
|
|
}
|
|
|
|
private async refreshSectionHeaderContent(
|
|
view: MarkdownView,
|
|
forceReplace: boolean,
|
|
target?: { text: string; level: string }
|
|
): Promise<void> {
|
|
if (!view.file) {
|
|
return;
|
|
}
|
|
|
|
this.removeStaleSectionHeaderContent(view);
|
|
const applicableRulesWithContent = await this._getApplicableRulesAndContent(view.file.path);
|
|
for (const { rule, contentText, index } of applicableRulesWithContent) {
|
|
if (rule.renderLocation !== RenderLocation.SectionHeader) {
|
|
continue;
|
|
}
|
|
if (
|
|
target &&
|
|
(
|
|
this.normalizeSectionHeaderText(rule.sectionHeaderText || '') !== target.text ||
|
|
(rule.sectionHeaderLevel || 'h2') !== target.level
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
await this.renderAndInjectSectionHeaderContent(view, contentText, rule, index, forceReplace);
|
|
}
|
|
}
|
|
|
|
private async renderAndInjectSectionHeaderContent(
|
|
view: MarkdownView,
|
|
contentText: string,
|
|
rule: Rule,
|
|
ruleIndex: number,
|
|
forceReplace: boolean = false
|
|
): Promise<void> {
|
|
if (!contentText || contentText.trim() === "" || !rule.sectionHeaderText?.trim()) {
|
|
return;
|
|
}
|
|
|
|
const sourcePath = view.file?.path || '';
|
|
const viewState = view.getState();
|
|
const existingSelector = `.${CSS_SECTION_HEADER_GROUP_ELEMENT}[data-rule-index="${ruleIndex}"][data-source-path="${sourcePath}"]`;
|
|
let container: HTMLElement | null = null;
|
|
let heading: HTMLElement | null = null;
|
|
|
|
if (viewState.mode !== 'preview') {
|
|
return;
|
|
}
|
|
|
|
container = view.previewMode.containerEl;
|
|
heading = this.findPreviewSectionTarget(container, rule);
|
|
|
|
if (!container || !heading) {
|
|
return;
|
|
}
|
|
|
|
if (this.isHeadingCollapsed(heading)) {
|
|
this.removeSectionHeaderContent(container, ruleIndex);
|
|
return;
|
|
}
|
|
|
|
if (!forceReplace && view.containerEl.querySelector(existingSelector)) {
|
|
return;
|
|
}
|
|
|
|
const component = new Component();
|
|
component.load();
|
|
|
|
const groupDiv = document.createElement('div') as HTMLElementWithComponent;
|
|
groupDiv.className = `${CSS_DYNAMIC_CONTENT_ELEMENT} ${CSS_SECTION_HEADER_GROUP_ELEMENT}`;
|
|
this.setSectionHeaderDataset(groupDiv, rule, ruleIndex);
|
|
groupDiv.dataset.sourcePath = sourcePath;
|
|
groupDiv.component = component;
|
|
|
|
try {
|
|
await MarkdownRenderer.render(this.app, contentText, groupDiv, sourcePath, component);
|
|
} catch (error) {
|
|
console.log("VirtualFooter: Error rendering section header content:", error);
|
|
component.unload();
|
|
return;
|
|
}
|
|
|
|
const placement = rule.sectionHeaderPlacement || 'top';
|
|
const level = this.getSectionHeaderLevelNumber(rule);
|
|
let injected = false;
|
|
|
|
if (viewState.mode === 'preview') {
|
|
if (heading) {
|
|
this.removeSectionHeaderContent(container, ruleIndex);
|
|
const anchor = heading.parentElement || heading;
|
|
if (placement === 'top') {
|
|
if (anchor !== heading) {
|
|
anchor.appendChild(groupDiv);
|
|
} else {
|
|
anchor.parentElement?.insertBefore(groupDiv, anchor.nextSibling);
|
|
}
|
|
} else {
|
|
const endNode = this.getPreviewSectionEnd(heading, level);
|
|
if (endNode) {
|
|
const previousElement = endNode.previousSibling instanceof HTMLElement ? endNode.previousSibling : null;
|
|
if (previousElement && previousElement !== anchor) {
|
|
previousElement.appendChild(groupDiv);
|
|
} else {
|
|
endNode.parentElement?.insertBefore(groupDiv, endNode);
|
|
}
|
|
} else {
|
|
anchor.parentElement?.appendChild(groupDiv);
|
|
}
|
|
}
|
|
injected = true;
|
|
this.updateSectionHeaderVisibility(container, false);
|
|
}
|
|
}
|
|
|
|
if (injected) {
|
|
this.attachInternalLinkHandlers(groupDiv, sourcePath, component);
|
|
} else {
|
|
component.unload();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders combined Markdown content and injects it into the specified location in the view.
|
|
* @param view The MarkdownView to inject content into.
|
|
* @param combinedContentText The combined Markdown string to render.
|
|
* @param renderLocation Specifies whether to render in the header or footer.
|
|
* @param renderAboveProperties For header content, whether to render above properties section.
|
|
* @param renderAboveBacklinks For footer content, whether to render above backlinks section.
|
|
* @returns The rendered HTMLElement if injection is deferred (for preview mode), otherwise null.
|
|
*/
|
|
private async renderAndInjectGroupedContent(
|
|
view: MarkdownView,
|
|
combinedContentText: string,
|
|
renderLocation: RenderLocation,
|
|
renderAboveProperties: boolean = false,
|
|
renderAboveBacklinks: boolean = false
|
|
): Promise<HTMLElementWithComponent | null> {
|
|
if (!combinedContentText || combinedContentText.trim() === "") {
|
|
return null;
|
|
}
|
|
|
|
const isRenderInHeader = renderLocation === RenderLocation.Header;
|
|
const sourcePath = view.file?.path || ''; // For MarkdownRenderer context
|
|
|
|
// Create container div for the content
|
|
const groupDiv = document.createElement('div') as HTMLElementWithComponent;
|
|
groupDiv.className = CSS_DYNAMIC_CONTENT_ELEMENT; // Base class for all injected content
|
|
groupDiv.classList.add(
|
|
isRenderInHeader ? CSS_HEADER_GROUP_ELEMENT : CSS_FOOTER_GROUP_ELEMENT,
|
|
isRenderInHeader ? CSS_HEADER_RENDERED_CONTENT : CSS_FOOTER_RENDERED_CONTENT
|
|
);
|
|
|
|
// Add the above-backlinks class for footer content when the setting is enabled
|
|
if (!isRenderInHeader && renderAboveBacklinks) {
|
|
groupDiv.classList.add(CSS_ABOVE_BACKLINKS);
|
|
groupDiv.classList.add('virtual-footer-above-backlinks');
|
|
}
|
|
|
|
// Add the above-properties class for header content when the setting is enabled
|
|
if (isRenderInHeader && renderAboveProperties) {
|
|
groupDiv.classList.add('virtual-footer-above-properties');
|
|
}
|
|
|
|
// Create and manage an Obsidian Component for the lifecycle of this content
|
|
const component = new Component();
|
|
component.load();
|
|
groupDiv.component = component;
|
|
|
|
// Try to render the Markdown content with retry logic for early load errors
|
|
try {
|
|
await MarkdownRenderer.render(this.app, combinedContentText, groupDiv, sourcePath, component);
|
|
} catch (error) {
|
|
console.log("VirtualFooter: Error during initial render, will retry after delay:", error);
|
|
|
|
// Add a placeholder while waiting to retry
|
|
const placeholderEl = groupDiv.createEl("div", { cls: "virtual-footer-loading" });
|
|
placeholderEl.createEl("p", { text: "Loading virtual content..." });
|
|
|
|
// Schedule a retry after a delay to allow other plugins to initialize
|
|
setTimeout(async () => {
|
|
try {
|
|
placeholderEl.remove();
|
|
await MarkdownRenderer.render(this.app, combinedContentText, groupDiv, sourcePath, component);
|
|
this.attachInternalLinkHandlers(groupDiv, sourcePath, component);
|
|
} catch (secondError) {
|
|
console.error("VirtualFooter: Failed to render content after retry:", secondError);
|
|
const errorEl = groupDiv.createEl("div", { cls: "virtual-footer-error" });
|
|
errorEl.createEl("p", { text: "Error rendering virtual content. Please reload the page or check the content for errors." });
|
|
}
|
|
}, 2000); // 2 second delay
|
|
}
|
|
|
|
let injectionSuccessful = false;
|
|
const viewState = view.getState();
|
|
|
|
// Inject based on view mode and render location
|
|
if (viewState.mode === 'preview') { // Reading mode
|
|
const previewContentParent = view.previewMode.containerEl;
|
|
let targetParent: HTMLElement | null = null;
|
|
|
|
if (isRenderInHeader) {
|
|
if (renderAboveProperties) {
|
|
// Try to find metadata container first
|
|
targetParent = previewContentParent.querySelector<HTMLElement>(SELECTOR_METADATA_CONTAINER);
|
|
}
|
|
// If no metadata container or renderAboveProperties is false, use regular header
|
|
if (!targetParent) {
|
|
targetParent = previewContentParent.querySelector<HTMLElement>(SELECTOR_PREVIEW_HEADER_AREA);
|
|
}
|
|
} else { // Footer
|
|
if (renderAboveBacklinks) {
|
|
// Try to find embedded backlinks first
|
|
targetParent = previewContentParent.querySelector<HTMLElement>(SELECTOR_EMBEDDED_BACKLINKS);
|
|
}
|
|
// If no backlinks or renderAboveBacklinks is false, use regular footer
|
|
if (!targetParent) {
|
|
targetParent = previewContentParent.querySelector<HTMLElement>(SELECTOR_PREVIEW_FOOTER_AREA);
|
|
}
|
|
}
|
|
|
|
if (targetParent) {
|
|
// Ensure idempotency: remove any existing content of this type before adding new
|
|
if (isRenderInHeader && renderAboveProperties) {
|
|
// Remove existing header content above properties
|
|
view.previewMode.containerEl.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else if (isRenderInHeader && !renderAboveProperties) {
|
|
// Remove existing normal header content
|
|
targetParent.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}:not(.virtual-footer-above-properties)`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else if (!isRenderInHeader && renderAboveBacklinks) {
|
|
// Remove existing footer content above backlinks
|
|
view.previewMode.containerEl.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else if (!isRenderInHeader && !renderAboveBacklinks) {
|
|
// Remove existing normal footer content
|
|
targetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}:not(.virtual-footer-above-backlinks)`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
}
|
|
|
|
if (isRenderInHeader && !renderAboveProperties) {
|
|
targetParent.appendChild(groupDiv);
|
|
} else if (!isRenderInHeader && !renderAboveBacklinks) {
|
|
targetParent.appendChild(groupDiv);
|
|
} else {
|
|
// Insert before properties or backlinks
|
|
targetParent.parentElement?.insertBefore(groupDiv, targetParent);
|
|
}
|
|
injectionSuccessful = true;
|
|
}
|
|
} else if (viewState.mode === 'source') { // Live Preview or Source mode
|
|
if (isRenderInHeader) {
|
|
let targetParent: HTMLElement | null = null;
|
|
|
|
if (renderAboveProperties) {
|
|
// Try to find metadata container first in live preview
|
|
targetParent = view.containerEl.querySelector<HTMLElement>(SELECTOR_METADATA_CONTAINER);
|
|
}
|
|
|
|
// If no metadata container or renderAboveProperties is false, use content container
|
|
if (!targetParent) {
|
|
const cmContentContainer = view.containerEl.querySelector<HTMLElement>(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER);
|
|
if (cmContentContainer?.parentElement) {
|
|
// Ensure idempotency: remove existing normal header content
|
|
cmContentContainer.parentElement.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}:not(.virtual-footer-above-properties)`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
cmContentContainer.parentElement.insertBefore(groupDiv, cmContentContainer);
|
|
injectionSuccessful = true;
|
|
}
|
|
} else {
|
|
// Ensure idempotency: remove existing header content above properties
|
|
view.containerEl.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
// Insert before properties
|
|
targetParent.parentElement?.insertBefore(groupDiv, targetParent);
|
|
injectionSuccessful = true;
|
|
}
|
|
} else { // Footer in Live Preview or Source mode
|
|
let targetParent: HTMLElement | null = null;
|
|
|
|
if (renderAboveBacklinks) {
|
|
// Try to find embedded backlinks first in live preview
|
|
targetParent = view.containerEl.querySelector<HTMLElement>(SELECTOR_EMBEDDED_BACKLINKS);
|
|
}
|
|
|
|
// If no backlinks or renderAboveBacklinks is false, use regular editor sizer
|
|
if (!targetParent) {
|
|
targetParent = view.containerEl.querySelector<HTMLElement>(SELECTOR_EDITOR_SIZER);
|
|
}
|
|
|
|
if (targetParent) {
|
|
// Ensure idempotency: remove existing content of the appropriate type
|
|
if (renderAboveBacklinks) {
|
|
// Remove existing footer content above backlinks
|
|
view.containerEl.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
} else {
|
|
// Remove existing normal footer content
|
|
targetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}:not(.virtual-footer-above-backlinks)`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
}
|
|
|
|
if (!renderAboveBacklinks || targetParent.matches(SELECTOR_EDITOR_SIZER)) {
|
|
targetParent.appendChild(groupDiv);
|
|
} else {
|
|
// Insert before backlinks
|
|
targetParent.parentElement?.insertBefore(groupDiv, targetParent);
|
|
}
|
|
injectionSuccessful = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (injectionSuccessful) {
|
|
this.attachInternalLinkHandlers(groupDiv, sourcePath, component);
|
|
return null; // Injection successful, no need to return element
|
|
} else {
|
|
// If injection failed in preview mode, it might be because the target DOM isn't ready.
|
|
// Return the div to be handled by the MutationObserver.
|
|
if (viewState.mode === 'preview') {
|
|
console.log(`VirtualFooter: Deferring injection for ${renderLocation} in preview mode. Target not found yet.`);
|
|
return groupDiv; // Return for deferred injection
|
|
} else {
|
|
// For other modes, if injection fails, unload component and log warning.
|
|
component.unload();
|
|
console.warn(`VirtualFooter: Failed to find injection point for dynamic content group (${renderLocation}). View mode: ${viewState.mode}.`);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures a MutationObserver is set up for a view in preview mode to handle deferred content injection.
|
|
* The observer watches for the appearance of target DOM elements and is careful not to act on stale data.
|
|
* @param view The MarkdownView to observe.
|
|
*/
|
|
private ensurePreviewObserver(view: MarkdownView): void {
|
|
if (this.previewObservers.has(view) || !view.file || !view.previewMode?.containerEl) {
|
|
return; // Observer already exists, or view/file/container not ready
|
|
}
|
|
|
|
const observerPath = view.file.path; // Path this observer is responsible for.
|
|
|
|
const observer = new MutationObserver((_mutations) => {
|
|
const pending = this.pendingPreviewInjections.get(view);
|
|
|
|
// This observer is stale and should self-destruct if:
|
|
// 1. The view has no file or has navigated to a different file.
|
|
// 2. There are no pending injections for this view.
|
|
// 3. The pending injections are for a different file.
|
|
if (!view.file || view.file.path !== observerPath || !pending || pending.filePath !== observerPath) {
|
|
observer.disconnect();
|
|
// Only remove this specific observer instance from the map
|
|
if (this.previewObservers.get(view) === observer) {
|
|
this.previewObservers.delete(view);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If there's nothing left to inject, clean up and disconnect.
|
|
if (!pending.headerDiv && !pending.footerDiv && !pending.headerAbovePropertiesDiv && !pending.footerAboveBacklinksDiv) {
|
|
observer.disconnect();
|
|
if (this.previewObservers.get(view) === observer) {
|
|
this.previewObservers.delete(view);
|
|
}
|
|
this.pendingPreviewInjections.delete(view);
|
|
return;
|
|
}
|
|
|
|
let allResolved = true;
|
|
const sourcePath = view.file.path;
|
|
|
|
// Attempt to inject pending header content
|
|
if (pending.headerDiv) {
|
|
const headerTargetParent = view.previewMode.containerEl.querySelector<HTMLElement>(SELECTOR_PREVIEW_HEADER_AREA);
|
|
if (headerTargetParent) {
|
|
// Ensure idempotency: remove any existing header content before adding new.
|
|
headerTargetParent.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
headerTargetParent.appendChild(pending.headerDiv);
|
|
if (pending.headerDiv.component) {
|
|
this.attachInternalLinkHandlers(pending.headerDiv, sourcePath, pending.headerDiv.component);
|
|
}
|
|
delete pending.headerDiv; // Injection successful
|
|
} else {
|
|
allResolved = false; // Target not yet available
|
|
}
|
|
}
|
|
|
|
// Attempt to inject pending header content above properties
|
|
if (pending.headerAbovePropertiesDiv) {
|
|
const headerTargetParent = view.previewMode.containerEl.querySelector<HTMLElement>(SELECTOR_METADATA_CONTAINER);
|
|
if (headerTargetParent) {
|
|
// Ensure idempotency: remove any existing content of this type
|
|
view.previewMode.containerEl.querySelectorAll(`.${CSS_HEADER_GROUP_ELEMENT}.virtual-footer-above-properties`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
// Add a class to distinguish this from regular header content
|
|
pending.headerAbovePropertiesDiv.classList.add('virtual-footer-above-properties');
|
|
// Insert before properties
|
|
headerTargetParent.parentElement?.insertBefore(pending.headerAbovePropertiesDiv, headerTargetParent);
|
|
if (pending.headerAbovePropertiesDiv.component) {
|
|
this.attachInternalLinkHandlers(pending.headerAbovePropertiesDiv, sourcePath, pending.headerAbovePropertiesDiv.component);
|
|
}
|
|
delete pending.headerAbovePropertiesDiv; // Injection successful
|
|
} else {
|
|
allResolved = false; // Target not yet available
|
|
}
|
|
}
|
|
|
|
// Attempt to inject pending footer content
|
|
if (pending.footerDiv) {
|
|
const footerTargetParent = view.previewMode.containerEl.querySelector<HTMLElement>(SELECTOR_PREVIEW_FOOTER_AREA);
|
|
if (footerTargetParent) {
|
|
// Ensure idempotency: remove any existing footer content before adding new.
|
|
footerTargetParent.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
footerTargetParent.appendChild(pending.footerDiv);
|
|
if (pending.footerDiv.component) {
|
|
this.attachInternalLinkHandlers(pending.footerDiv, sourcePath, pending.footerDiv.component);
|
|
}
|
|
delete pending.footerDiv; // Injection successful
|
|
} else {
|
|
allResolved = false; // Target not yet available
|
|
}
|
|
}
|
|
|
|
// Attempt to inject pending footer content above backlinks
|
|
if (pending.footerAboveBacklinksDiv) {
|
|
const footerTargetParent = view.previewMode.containerEl.querySelector<HTMLElement>(SELECTOR_EMBEDDED_BACKLINKS);
|
|
if (footerTargetParent) {
|
|
// Ensure idempotency: remove any existing content of this type
|
|
view.previewMode.containerEl.querySelectorAll(`.${CSS_FOOTER_GROUP_ELEMENT}.virtual-footer-above-backlinks`).forEach(el => {
|
|
const holder = el as HTMLElementWithComponent;
|
|
holder.component?.unload();
|
|
el.remove();
|
|
});
|
|
// Add a class to distinguish this from regular footer content
|
|
pending.footerAboveBacklinksDiv.classList.add('virtual-footer-above-backlinks');
|
|
// Insert before backlinks
|
|
footerTargetParent.parentElement?.insertBefore(pending.footerAboveBacklinksDiv, footerTargetParent);
|
|
if (pending.footerAboveBacklinksDiv.component) {
|
|
this.attachInternalLinkHandlers(pending.footerAboveBacklinksDiv, sourcePath, pending.footerAboveBacklinksDiv.component);
|
|
}
|
|
delete pending.footerAboveBacklinksDiv; // Injection successful
|
|
} else {
|
|
allResolved = false; // Target not yet available
|
|
}
|
|
}
|
|
|
|
// If all pending injections are resolved, disconnect the observer
|
|
if (allResolved) {
|
|
observer.disconnect();
|
|
if (this.previewObservers.get(view) === observer) {
|
|
this.previewObservers.delete(view);
|
|
}
|
|
this.pendingPreviewInjections.delete(view);
|
|
}
|
|
});
|
|
|
|
// Start observing the preview container for child and subtree changes
|
|
observer.observe(view.previewMode.containerEl, { childList: true, subtree: true });
|
|
this.previewObservers.set(view, observer);
|
|
}
|
|
|
|
/**
|
|
* Applies CSS classes to adjust CodeMirror (Live Preview) layout for footer content.
|
|
* @param view The MarkdownView in Live Preview mode.
|
|
*/
|
|
private computeFooterBottomPaddingPx(containerHeight: number): number {
|
|
const rawPadding = Math.round(containerHeight * VIRTUAL_FOOTER_BOTTOM_PADDING_RATIO);
|
|
return Math.max(VIRTUAL_FOOTER_BOTTOM_PADDING_MIN, Math.min(VIRTUAL_FOOTER_BOTTOM_PADDING_MAX, rawPadding));
|
|
}
|
|
|
|
private applyFooterBottomPadding(view: MarkdownView): void {
|
|
const updatePadding = () => {
|
|
const containerHeight = view.containerEl.clientHeight;
|
|
if (!containerHeight) {
|
|
return;
|
|
}
|
|
const paddingPx = this.computeFooterBottomPaddingPx(containerHeight);
|
|
view.containerEl.style.setProperty(CSS_VIRTUAL_FOOTER_BOTTOM_PADDING_VAR, `${paddingPx}px`);
|
|
};
|
|
|
|
updatePadding();
|
|
if (!this.footerPaddingObservers.has(view)) {
|
|
const observer = new ResizeObserver(() => {
|
|
updatePadding();
|
|
});
|
|
observer.observe(view.containerEl);
|
|
this.footerPaddingObservers.set(view, observer);
|
|
}
|
|
}
|
|
|
|
private removeFooterBottomPadding(view: MarkdownView): void {
|
|
const observer = this.footerPaddingObservers.get(view);
|
|
if (observer) {
|
|
observer.disconnect();
|
|
this.footerPaddingObservers.delete(view);
|
|
}
|
|
view.containerEl.style.removeProperty(CSS_VIRTUAL_FOOTER_BOTTOM_PADDING_VAR);
|
|
}
|
|
|
|
/**
|
|
* Applies CSS classes to adjust CodeMirror (Live Preview) layout for footer content.
|
|
* @param view The MarkdownView in Live Preview mode.
|
|
*/
|
|
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);
|
|
containerEl?.classList.add(CSS_VIRTUAL_FOOTER_REMOVE_FLEX);
|
|
|
|
// Keep Live Preview bottom spacing stable even when Obsidian rewrites inline styles.
|
|
if (contentEl) {
|
|
const observedContentEl = contentEl as HTMLDivElement & { observer?: MutationObserver };
|
|
const expectedPadding = 'var(--p-spacing)';
|
|
const enforcePadding = () => {
|
|
const currentPadding = contentEl.style.getPropertyValue('padding-bottom');
|
|
const currentPriority = contentEl.style.getPropertyPriority('padding-bottom');
|
|
if (currentPadding !== expectedPadding || currentPriority !== 'important') {
|
|
contentEl.style.setProperty('padding-bottom', expectedPadding, 'important');
|
|
}
|
|
};
|
|
|
|
enforcePadding();
|
|
|
|
// Watch for style mutations and reapply if needed.
|
|
observedContentEl.observer?.disconnect();
|
|
const observer = new MutationObserver(() => {
|
|
enforcePadding();
|
|
});
|
|
|
|
observer.observe(contentEl, { attributes: true, attributeFilter: ['style'] });
|
|
|
|
// Store observer so it can be disconnected during cleanup.
|
|
observedContentEl.observer = observer;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes CSS classes used for Live Preview footer layout adjustments.
|
|
* @param viewOrContainer The MarkdownView or a specific HTMLElement container.
|
|
*/
|
|
private removeLivePreviewFooterStyles(viewOrContainer: MarkdownView | HTMLElement): void {
|
|
const container = viewOrContainer instanceof MarkdownView ? viewOrContainer.containerEl : viewOrContainer;
|
|
const contentEl = container.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_AREA);
|
|
const containerEl = container.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT);
|
|
contentEl?.classList.remove(CSS_VIRTUAL_FOOTER_CM_PADDING);
|
|
containerEl?.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX);
|
|
|
|
// Remove the inline padding-bottom style
|
|
if (contentEl) {
|
|
const observedContentEl = contentEl as HTMLDivElement & { observer?: MutationObserver };
|
|
contentEl.style.removeProperty('padding-bottom');
|
|
if (observedContentEl.observer) {
|
|
observedContentEl.observer.disconnect();
|
|
delete observedContentEl.observer;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes all plugin-injected DOM elements from a given container.
|
|
* @param containerEl The HTMLElement to search within.
|
|
*/
|
|
private async removeInjectedContentDOM(containerEl: HTMLElement, preserveSectionHeader: boolean = false): Promise<void> {
|
|
containerEl.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => {
|
|
if (preserveSectionHeader && el.classList.contains(CSS_SECTION_HEADER_GROUP_ELEMENT)) {
|
|
return;
|
|
}
|
|
const componentHolder = el as HTMLElementWithComponent;
|
|
if (componentHolder.component) {
|
|
componentHolder.component.unload(); // Unload associated Obsidian component
|
|
}
|
|
el.remove(); // Remove the element from DOM
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes all dynamic content, styles, and observers associated with a specific view.
|
|
* @param view The MarkdownView to clean up.
|
|
*/
|
|
private async removeDynamicContentFromView(view: MarkdownView, preserveSectionHeader: boolean = false): Promise<void> {
|
|
this.removeLivePreviewFooterStyles(view);
|
|
this.removeFooterBottomPadding(view);
|
|
await this.removeInjectedContentDOM(view.containerEl, preserveSectionHeader);
|
|
|
|
// Disconnect and remove observer for this view
|
|
const observer = this.previewObservers.get(view);
|
|
if (observer) {
|
|
observer.disconnect();
|
|
this.previewObservers.delete(view);
|
|
}
|
|
|
|
// Clean up any pending injections for this view
|
|
const pending = this.pendingPreviewInjections.get(view);
|
|
if (pending) {
|
|
pending.headerDiv?.component?.unload();
|
|
pending.footerDiv?.component?.unload();
|
|
this.pendingPreviewInjections.delete(view);
|
|
}
|
|
|
|
// Disconnect embed observer for this view
|
|
const embedObserver = this.embedObservers.get(view);
|
|
if (embedObserver) {
|
|
embedObserver.disconnect();
|
|
this.embedObservers.delete(view);
|
|
}
|
|
const embedRefreshTimeout = this.embedRefreshTimeouts.get(view);
|
|
if (embedRefreshTimeout) {
|
|
window.clearTimeout(embedRefreshTimeout);
|
|
this.embedRefreshTimeouts.delete(view);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears dynamic content from all currently open Markdown views.
|
|
* Typically used during plugin unload or when global settings change significantly.
|
|
*/
|
|
private clearAllViewsDynamicContent(): void {
|
|
this.app.workspace.getLeavesOfType('markdown').forEach(leaf => {
|
|
if (leaf.view instanceof MarkdownView) {
|
|
this.removeDynamicContentFromView(leaf.view);
|
|
}
|
|
});
|
|
// Also clear sidebar
|
|
this.lastSidebarContent = null;
|
|
this.lastSeparateTabContents.clear();
|
|
this.updateAllSidebarViews();
|
|
}
|
|
|
|
/**
|
|
* Determines which rules apply to a given file path and fetches their content.
|
|
* @param filePath The path of the file to check against rules.
|
|
* @returns A promise that resolves to an array of objects, each containing an applicable rule and its content.
|
|
*/
|
|
private async _getApplicableRulesAndContent(filePath: string): Promise<Array<{ rule: Rule; contentText: string; index: number }>> {
|
|
const allApplicable: Array<{ rule: Rule; contentText: string; index: number }> = [];
|
|
const abstractFile = this.app.vault.getAbstractFileByPath(filePath);
|
|
|
|
if (!(abstractFile instanceof TFile)) {
|
|
return []; // Not a valid file
|
|
}
|
|
const file: TFile = abstractFile;
|
|
let fileTags: string[] | null = null; // Lazily loaded
|
|
const fileCache = this.app.metadataCache.getFileCache(file);
|
|
|
|
// Pre-fetch tags if any tag-based rules exist and are enabled
|
|
const hasEnabledTagRule = this.settings.rules.some(r => r.enabled && (r.type === RuleType.Tag || r.type === RuleType.Multi));
|
|
if (hasEnabledTagRule && fileCache) {
|
|
const allTagsInFileWithHash = getAllTags(fileCache);
|
|
fileTags = allTagsInFileWithHash ? allTagsInFileWithHash.map(tag => tag.substring(1)) : [];
|
|
}
|
|
|
|
for (const [index, currentRule] of this.settings.rules.entries()) {
|
|
if (!currentRule.enabled) {
|
|
continue; // Skip disabled rules
|
|
}
|
|
|
|
let isMatch = false;
|
|
|
|
// --- Match by Folder ---
|
|
if (currentRule.type === RuleType.Folder) {
|
|
isMatch = this._checkFolderMatch(file, currentRule);
|
|
}
|
|
// --- Match by Tag ---
|
|
else if (currentRule.type === RuleType.Tag) {
|
|
isMatch = this._checkTagMatch(fileTags, currentRule);
|
|
}
|
|
// --- Match by Property ---
|
|
else if (currentRule.type === RuleType.Property) {
|
|
isMatch = this._checkPropertyMatch(fileCache?.frontmatter, currentRule);
|
|
}
|
|
// --- Match by Multi ---
|
|
else if (currentRule.type === RuleType.Multi) {
|
|
if (currentRule.conditions && currentRule.conditions.length > 0) {
|
|
const checkCondition = (condition: SubCondition): boolean => {
|
|
let result = false;
|
|
if (condition.type === 'folder') {
|
|
result = this._checkFolderMatch(file, condition);
|
|
} else if (condition.type === 'tag') {
|
|
result = this._checkTagMatch(fileTags, condition);
|
|
} else if (condition.type === 'property') {
|
|
result = this._checkPropertyMatch(fileCache?.frontmatter, condition);
|
|
}
|
|
|
|
// Apply negation if specified
|
|
return condition.negated ? !result : result;
|
|
};
|
|
|
|
if (currentRule.multiConditionLogic === 'all') {
|
|
// ALL (AND) logic: every condition must be true
|
|
isMatch = currentRule.conditions.every(checkCondition);
|
|
} else {
|
|
// ANY (OR) logic: at least one condition must be true
|
|
isMatch = currentRule.conditions.some(checkCondition);
|
|
}
|
|
}
|
|
}
|
|
// --- Match by Dataview Query ---
|
|
else if (currentRule.type === RuleType.Dataview) {
|
|
isMatch = await this._checkDataviewMatch(file, currentRule.dataviewQuery || '');
|
|
}
|
|
|
|
// Apply negation to the main rule if specified (for non-multi rules)
|
|
if (currentRule.type !== RuleType.Multi && currentRule.negated) {
|
|
isMatch = !isMatch;
|
|
}
|
|
|
|
if (isMatch) {
|
|
const contentText = await this._fetchContentForRule(currentRule);
|
|
allApplicable.push({ rule: currentRule, contentText, index });
|
|
}
|
|
}
|
|
return allApplicable;
|
|
}
|
|
|
|
private _checkFolderMatch(file: TFile, rule: { path?: string, recursive?: boolean }): boolean {
|
|
if (rule.path === undefined) return false;
|
|
const ruleRecursive = rule.recursive === undefined ? true : rule.recursive;
|
|
|
|
if (rule.path === "") { // Matches all files
|
|
return true;
|
|
} else if (rule.path === "/") { // Matches root folder
|
|
return ruleRecursive ? true : (file.parent?.isRoot() ?? false);
|
|
} else {
|
|
let normalizedRuleFolderPath = rule.path.endsWith('/') ? rule.path.slice(0, -1) : rule.path;
|
|
if (ruleRecursive) {
|
|
return file.path.startsWith(normalizedRuleFolderPath + '/');
|
|
} else {
|
|
return file.parent?.path === normalizedRuleFolderPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
private _checkTagMatch(fileTags: string[] | null, rule: { tag?: string, includeSubtags?: boolean }): boolean {
|
|
if (!rule.tag || !fileTags) return false;
|
|
const ruleTag = rule.tag;
|
|
const includeSubtags = rule.includeSubtags ?? false;
|
|
for (const fileTag of fileTags) {
|
|
if (includeSubtags) {
|
|
if (fileTag === ruleTag || fileTag.startsWith(ruleTag + '/')) {
|
|
return true;
|
|
}
|
|
} else {
|
|
if (fileTag === ruleTag) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private _checkPropertyMatch(frontmatter: any, rule: { propertyName?: string, propertyValue?: string }): boolean {
|
|
if (!rule.propertyName || !frontmatter) return false;
|
|
const propertyKey = rule.propertyName;
|
|
const expectedPropertyValue = rule.propertyValue;
|
|
const actualPropertyValue = frontmatter[propertyKey];
|
|
|
|
// If the property exists in frontmatter
|
|
if (actualPropertyValue !== undefined && actualPropertyValue !== null) {
|
|
// If no expected value is specified, match any file that has this property
|
|
if (!expectedPropertyValue || expectedPropertyValue.trim() === '') {
|
|
return true;
|
|
}
|
|
|
|
// Smart Property Links Logic
|
|
if (this.settings.smartPropertyLinks) {
|
|
const resolveFile = (val: string) => {
|
|
if (!val) return null;
|
|
let linktext = val.trim();
|
|
// Basic wiki-link cleaning: [[Link|Alias]] -> Link
|
|
if (linktext.startsWith('[[') && linktext.endsWith(']]')) {
|
|
linktext = linktext.substring(2, linktext.length - 2);
|
|
const pipeIndex = linktext.indexOf('|');
|
|
if (pipeIndex >= 0) {
|
|
linktext = linktext.substring(0, pipeIndex);
|
|
}
|
|
}
|
|
return this.app.metadataCache.getFirstLinkpathDest(linktext, '');
|
|
};
|
|
|
|
const expectedFile = resolveFile(expectedPropertyValue);
|
|
if (expectedFile) {
|
|
const checkValue = (val: any) => {
|
|
if (typeof val !== 'string') return false;
|
|
const actualFile = resolveFile(val);
|
|
return actualFile !== null && actualFile.path === expectedFile.path;
|
|
};
|
|
|
|
if (Array.isArray(actualPropertyValue)) {
|
|
if (actualPropertyValue.some(checkValue)) return true;
|
|
} else {
|
|
if (checkValue(actualPropertyValue)) return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Otherwise, check for exact value match
|
|
if (typeof actualPropertyValue === 'string') {
|
|
return actualPropertyValue === expectedPropertyValue;
|
|
} else if (Array.isArray(actualPropertyValue)) {
|
|
// For arrays, check if the expected value is one of the items
|
|
return actualPropertyValue.map(String).includes(expectedPropertyValue);
|
|
} else if (typeof actualPropertyValue === 'number' || typeof actualPropertyValue === 'boolean') {
|
|
return String(actualPropertyValue) === expectedPropertyValue;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if a file matches a Dataview query rule
|
|
* @param file The file to check against the dataview query
|
|
* @param query The dataview query string
|
|
* @returns True if the file matches the dataview query, false otherwise
|
|
*/
|
|
private async _checkDataviewMatch(file: TFile, query: string): Promise<boolean> {
|
|
// Check if dataview plugin exists
|
|
// @ts-ignore - Access plugins using bracket notation
|
|
const dataviewPlugin = this.app.plugins.plugins?.dataview;
|
|
if (!dataviewPlugin) {
|
|
console.warn("VirtualFooter: Dataview plugin is required for dataview rules but is not installed or enabled.");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const dataviewApi = dataviewPlugin.api;
|
|
if (!dataviewApi) {
|
|
console.warn("VirtualFooter: Cannot access Dataview API.");
|
|
return false;
|
|
}
|
|
|
|
// Execute the query against the active file
|
|
const results = await dataviewApi.query(query);
|
|
|
|
// Dataview API returns a Success object with a 'successful' flag and 'value' property
|
|
if (!results || !results.successful || !results.value || !Array.isArray(results.value.values)) {
|
|
// If the query did not return valid results, log and return false
|
|
console.warn(`VirtualFooter: Dataview query did not return valid results for query: ${query} in file: ${file.path} Dataview error:`, results);
|
|
return false;
|
|
}
|
|
|
|
// Extract file paths from the results
|
|
const resultPaths: string[] = [];
|
|
for (const page of results.value.values) {
|
|
if (page.path) {
|
|
resultPaths.push(page.path);
|
|
}
|
|
}
|
|
|
|
// Check if current file path is in the results
|
|
return resultPaths.includes(file.path);
|
|
} catch (error) {
|
|
console.error(`VirtualFooter: Error executing Dataview query: ${query}`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches the content for a given rule, either from direct text or from a specified file.
|
|
* @param rule The rule for which to fetch content.
|
|
* @returns A promise that resolves to the content string.
|
|
*/
|
|
private async _fetchContentForRule(rule: Rule): Promise<string> {
|
|
if (rule.contentSource === ContentSource.File && rule.footerFilePath) {
|
|
const file = this.app.vault.getAbstractFileByPath(rule.footerFilePath);
|
|
if (file instanceof TFile) {
|
|
try {
|
|
return await this.app.vault.cachedRead(file);
|
|
} catch (error) {
|
|
console.error(`VirtualFooter: Error reading content file ${rule.footerFilePath}`, error);
|
|
return `<!-- Error reading content file: ${rule.footerFilePath} -->`; // Return error message in content
|
|
}
|
|
} else {
|
|
console.warn(`VirtualFooter: Content file not found for rule: ${rule.footerFilePath}`);
|
|
return `<!-- Content file not found: ${rule.footerFilePath} -->`; // Return warning in content
|
|
}
|
|
}
|
|
return rule.footerText || ""; // Use direct text or empty string if not file
|
|
}
|
|
|
|
/**
|
|
* Attaches event handlers to the injected content for internal link navigation.
|
|
* @param container The HTMLElement containing the rendered Markdown.
|
|
* @param sourcePath The path of the file where the content is injected, for link resolution.
|
|
* @param component The Obsidian Component associated with this content, for event registration.
|
|
*/
|
|
public attachInternalLinkHandlers(container: HTMLElement, sourcePath: string, component: Component): void {
|
|
// If in reading view, do nothing, as the default behavior is fine
|
|
if (container.closest(".markdown-reading-view")) return;
|
|
|
|
// Handle left-click on internal links and external file links
|
|
component.registerDomEvent(container, 'click', (event: MouseEvent) => {
|
|
if (event.button !== 0) return; // Only handle left-clicks
|
|
const target = event.target as HTMLElement;
|
|
|
|
const linkElement = target.closest('a.internal-link') as HTMLAnchorElement;
|
|
if (linkElement) {
|
|
event.preventDefault(); // Prevent default link navigation
|
|
const href = linkElement.dataset.href;
|
|
if (href) {
|
|
const inNewPane = event.ctrlKey || event.metaKey; // Open in new pane if Ctrl/Cmd is pressed
|
|
this.app.workspace.openLinkText(href, sourcePath, inNewPane);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle external file links which don't work natively in Live Preview injected content
|
|
const externalLink = target.closest('a.external-link') as HTMLAnchorElement;
|
|
if (externalLink && externalLink.href.startsWith('file:')) {
|
|
event.preventDefault();
|
|
window.open(externalLink.href);
|
|
}
|
|
});
|
|
|
|
// Handle middle-click (auxclick) on internal links to open in a new pane
|
|
component.registerDomEvent(container, 'auxclick', (event: MouseEvent) => {
|
|
if (event.button !== 1) return; // Only handle middle-clicks
|
|
const target = event.target as HTMLElement;
|
|
const linkElement = target.closest('a.internal-link') as HTMLAnchorElement;
|
|
if (linkElement) {
|
|
event.preventDefault();
|
|
const href = linkElement.dataset.href;
|
|
if (href) {
|
|
this.app.workspace.openLinkText(href, sourcePath, true); // Always open in new pane for middle-click
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle external file links which don't work natively in Live Preview injected content
|
|
const externalLink = target.closest('a.external-link') as HTMLAnchorElement;
|
|
if (externalLink && externalLink.href.startsWith('file:')) {
|
|
event.preventDefault();
|
|
window.open(externalLink.href);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Loads plugin settings from storage, migrating old formats if necessary.
|
|
*/
|
|
async loadSettings() {
|
|
const loadedData = await this.loadData();
|
|
// Start with a deep copy of default settings to ensure all fields are present
|
|
this.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
|
|
if (loadedData) {
|
|
// Handle potential old global renderLocation setting for migration
|
|
const oldGlobalRenderLocation = loadedData.renderLocation as RenderLocation | undefined;
|
|
|
|
if (loadedData.rules && Array.isArray(loadedData.rules)) {
|
|
this.settings.rules = loadedData.rules.map((loadedRule: any) =>
|
|
this._migrateRule(loadedRule, oldGlobalRenderLocation)
|
|
);
|
|
}
|
|
// Load the new refreshOnFileOpen setting if it exists in loadedData
|
|
if (typeof loadedData.refreshOnFileOpen === 'boolean') {
|
|
this.settings.refreshOnFileOpen = loadedData.refreshOnFileOpen;
|
|
}
|
|
// Load the new renderInSourceMode setting if it exists
|
|
if (typeof loadedData.renderInSourceMode === 'boolean') {
|
|
this.settings.renderInSourceMode = loadedData.renderInSourceMode;
|
|
}
|
|
// Load the new refreshOnMetadataChange setting if it exists
|
|
if (typeof loadedData.refreshOnMetadataChange === 'boolean') {
|
|
this.settings.refreshOnMetadataChange = loadedData.refreshOnMetadataChange;
|
|
}
|
|
// Load the new smartPropertyLinks setting if it exists
|
|
if (typeof loadedData.smartPropertyLinks === 'boolean') {
|
|
this.settings.smartPropertyLinks = loadedData.smartPropertyLinks;
|
|
}
|
|
// Load the new enableEmbedRendering setting if it exists
|
|
if (typeof loadedData.enableEmbedRendering === 'boolean') {
|
|
this.settings.enableEmbedRendering = loadedData.enableEmbedRendering;
|
|
}
|
|
// Load the new enableCanvasRendering setting if it exists
|
|
if (typeof loadedData.enableCanvasRendering === 'boolean') {
|
|
this.settings.enableCanvasRendering = loadedData.enableCanvasRendering;
|
|
}
|
|
// Load the new debugEmbedCanvas setting if it exists
|
|
if (typeof loadedData.debugEmbedCanvas === 'boolean') {
|
|
this.settings.debugEmbedCanvas = loadedData.debugEmbedCanvas;
|
|
}
|
|
}
|
|
|
|
// Ensure there's at least one rule, and all rules are normalized
|
|
if (!this.settings.rules || this.settings.rules.length === 0) {
|
|
// If no rules exist, add a default one
|
|
this.settings.rules = [JSON.parse(JSON.stringify(DEFAULT_SETTINGS.rules[0]))];
|
|
this.normalizeRule(this.settings.rules[0]);
|
|
} else {
|
|
// Normalize all existing rules
|
|
this.settings.rules.forEach(rule => this.normalizeRule(rule));
|
|
}
|
|
// Ensure global settings are definitely booleans
|
|
if (typeof this.settings.refreshOnFileOpen !== 'boolean') {
|
|
this.settings.refreshOnFileOpen = DEFAULT_SETTINGS.refreshOnFileOpen!;
|
|
}
|
|
if (typeof this.settings.renderInSourceMode !== 'boolean') {
|
|
this.settings.renderInSourceMode = DEFAULT_SETTINGS.renderInSourceMode!;
|
|
}
|
|
if (typeof this.settings.refreshOnMetadataChange !== 'boolean') {
|
|
this.settings.refreshOnMetadataChange = DEFAULT_SETTINGS.refreshOnMetadataChange!;
|
|
}
|
|
if (typeof this.settings.smartPropertyLinks !== 'boolean') {
|
|
this.settings.smartPropertyLinks = DEFAULT_SETTINGS.smartPropertyLinks!;
|
|
}
|
|
if (typeof this.settings.enableEmbedRendering !== 'boolean') {
|
|
this.settings.enableEmbedRendering = DEFAULT_SETTINGS.enableEmbedRendering!;
|
|
}
|
|
if (typeof this.settings.enableCanvasRendering !== 'boolean') {
|
|
this.settings.enableCanvasRendering = DEFAULT_SETTINGS.enableCanvasRendering!;
|
|
}
|
|
if (typeof this.settings.debugEmbedCanvas !== 'boolean') {
|
|
this.settings.debugEmbedCanvas = DEFAULT_SETTINGS.debugEmbedCanvas!;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrates a rule from an older settings format to the current Rule interface.
|
|
* @param loadedRule The rule object loaded from storage.
|
|
* @param globalRenderLocation An optional global render location from a very old settings format.
|
|
* @returns A migrated and normalized Rule object.
|
|
*/
|
|
private _migrateRule(loadedRule: any, globalRenderLocation?: RenderLocation): Rule {
|
|
const hasNegated = Object.prototype.hasOwnProperty.call(loadedRule, 'negated');
|
|
const migratedNegated = hasNegated
|
|
? normalizeBoolean(loadedRule.negated, false)
|
|
: (DEFAULT_SETTINGS.rules[0].negated ?? false);
|
|
|
|
// Determine rule type, defaulting if ambiguous
|
|
let type: RuleType;
|
|
// Avoid Object.values for older TS lib targets: check enum values via keys
|
|
if ((Object.keys(RuleType) as string[]).some(k => (RuleType as any)[k] === loadedRule.type)) {
|
|
type = loadedRule.type as RuleType;
|
|
} else if (typeof loadedRule.folderPath === 'string') { // Legacy field
|
|
type = RuleType.Folder;
|
|
} else {
|
|
type = DEFAULT_SETTINGS.rules[0].type;
|
|
}
|
|
|
|
// Determine content source, defaulting if ambiguous
|
|
let contentSource: ContentSource;
|
|
if ((Object.keys(ContentSource) as string[]).some(k => (ContentSource as any)[k] === loadedRule.contentSource)) {
|
|
contentSource = loadedRule.contentSource as ContentSource;
|
|
} else {
|
|
// If folderPath existed (legacy) and contentSource is undefined, it was likely Text
|
|
contentSource = (typeof loadedRule.folderPath === 'string' && loadedRule.contentSource === undefined)
|
|
? ContentSource.Text
|
|
: DEFAULT_SETTINGS.rules[0].contentSource;
|
|
}
|
|
|
|
const migratedRule: Rule = {
|
|
name: loadedRule.name || DEFAULT_SETTINGS.rules[0].name,
|
|
enabled: loadedRule.enabled !== undefined ? loadedRule.enabled : DEFAULT_SETTINGS.rules[0].enabled,
|
|
type: type,
|
|
negated: migratedNegated,
|
|
contentSource: contentSource,
|
|
footerText: loadedRule.footerText || '', // Retain name for compatibility
|
|
renderLocation: loadedRule.renderLocation || globalRenderLocation || DEFAULT_SETTINGS.rules[0].renderLocation,
|
|
recursive: loadedRule.recursive !== undefined ? loadedRule.recursive : true,
|
|
showInSeparateTab: loadedRule.showInSeparateTab || false,
|
|
sidebarTabName: loadedRule.sidebarTabName || '',
|
|
multiConditionLogic: loadedRule.multiConditionLogic || 'any',
|
|
renderAboveProperties: loadedRule.renderAboveProperties !== undefined ? loadedRule.renderAboveProperties : undefined,
|
|
renderAboveBacklinks: loadedRule.renderAboveBacklinks !== undefined ? loadedRule.renderAboveBacklinks : undefined,
|
|
sectionHeaderText: loadedRule.sectionHeaderText || '',
|
|
sectionHeaderLevel: loadedRule.sectionHeaderLevel || 'h2',
|
|
sectionHeaderPlacement: loadedRule.sectionHeaderPlacement === 'bottom' ? 'bottom' : 'top',
|
|
dataviewQuery: loadedRule.dataviewQuery || '',
|
|
footerFilePath: loadedRule.footerFilePath || '', // Retained name for compatibility
|
|
showInPopover: loadedRule.showInPopover !== undefined ? loadedRule.showInPopover : true,
|
|
showInEmbed: loadedRule.showInEmbed !== undefined ? loadedRule.showInEmbed : true,
|
|
showInCanvas: loadedRule.showInCanvas !== undefined ? loadedRule.showInCanvas : true,
|
|
};
|
|
|
|
// Populate type-specific fields
|
|
if (migratedRule.type === RuleType.Folder) {
|
|
migratedRule.path = loadedRule.path !== undefined ? loadedRule.path :
|
|
(loadedRule.folderPath !== undefined ? loadedRule.folderPath : DEFAULT_SETTINGS.rules[0].path);
|
|
} else if (migratedRule.type === RuleType.Tag) {
|
|
migratedRule.tag = loadedRule.tag !== undefined ? loadedRule.tag : '';
|
|
migratedRule.includeSubtags = loadedRule.includeSubtags !== undefined ? loadedRule.includeSubtags : false;
|
|
} else if (migratedRule.type === RuleType.Property) {
|
|
migratedRule.propertyName = loadedRule.propertyName || '';
|
|
migratedRule.propertyValue = loadedRule.propertyValue || '';
|
|
} else if (migratedRule.type === RuleType.Multi) {
|
|
migratedRule.conditions = Array.isArray(loadedRule.conditions)
|
|
? loadedRule.conditions.map((condition: any) => ({
|
|
type: condition?.type,
|
|
negated: normalizeBoolean(condition?.negated, false),
|
|
path: condition?.path,
|
|
recursive: condition?.recursive,
|
|
tag: condition?.tag,
|
|
includeSubtags: condition?.includeSubtags,
|
|
propertyName: condition?.propertyName,
|
|
propertyValue: condition?.propertyValue,
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
// Populate content source-specific fields
|
|
if (migratedRule.contentSource === ContentSource.File) {
|
|
migratedRule.footerFilePath = loadedRule.footerFilePath || ''; // Retained name for compatibility
|
|
}
|
|
return migratedRule; // Normalization will happen after migration
|
|
}
|
|
|
|
/**
|
|
* Normalizes a rule object, ensuring all required fields are present and defaults are applied.
|
|
* Also cleans up fields that are not relevant to the rule's current type or content source.
|
|
* @param rule The rule to normalize.
|
|
*/
|
|
public normalizeRule(rule: Rule): void {
|
|
// Create a copy of the rule to preserve original values during cleanup
|
|
const originalRule = { ...rule };
|
|
|
|
// Ensure basic fields have default values
|
|
rule.name = rule.name === undefined ? DEFAULT_SETTINGS.rules[0].name : rule.name;
|
|
rule.enabled = typeof rule.enabled === 'boolean' ? rule.enabled : DEFAULT_SETTINGS.rules[0].enabled!;
|
|
rule.type = rule.type || DEFAULT_SETTINGS.rules[0].type;
|
|
rule.negated = normalizeBoolean(originalRule.negated, DEFAULT_SETTINGS.rules[0].negated ?? false);
|
|
|
|
// Clean up all type-specific fields before re-populating
|
|
delete rule.path;
|
|
delete rule.recursive;
|
|
delete rule.tag;
|
|
delete rule.includeSubtags;
|
|
delete rule.propertyName;
|
|
delete rule.propertyValue;
|
|
delete rule.conditions;
|
|
delete rule.multiConditionLogic;
|
|
delete rule.dataviewQuery;
|
|
|
|
// Normalize based on RuleType, using values from the original rule if they exist
|
|
if (rule.type === RuleType.Folder) {
|
|
rule.path = originalRule.path === undefined ? (DEFAULT_SETTINGS.rules[0].path || '') : originalRule.path;
|
|
// 'recursive' is always true if path is "" (all files)
|
|
rule.recursive = rule.path === "" ? true : (typeof originalRule.recursive === 'boolean' ? originalRule.recursive : true);
|
|
} else if (rule.type === RuleType.Tag) {
|
|
rule.tag = originalRule.tag === undefined ? '' : originalRule.tag;
|
|
rule.includeSubtags = typeof originalRule.includeSubtags === 'boolean' ? originalRule.includeSubtags : false;
|
|
} else if (rule.type === RuleType.Property) {
|
|
rule.propertyName = originalRule.propertyName === undefined ? '' : originalRule.propertyName;
|
|
rule.propertyValue = originalRule.propertyValue === undefined ? '' : originalRule.propertyValue;
|
|
} else if (rule.type === RuleType.Multi) {
|
|
rule.conditions = Array.isArray(originalRule.conditions)
|
|
? originalRule.conditions.map((condition) => ({
|
|
...condition,
|
|
negated: normalizeBoolean(condition.negated, false),
|
|
}))
|
|
: [];
|
|
rule.multiConditionLogic = originalRule.multiConditionLogic === 'all' ? 'all' : 'any';
|
|
} else if (rule.type === RuleType.Dataview) {
|
|
rule.dataviewQuery = originalRule.dataviewQuery === undefined ? '' : originalRule.dataviewQuery;
|
|
}
|
|
|
|
// Normalize content source and related fields
|
|
rule.contentSource = originalRule.contentSource || DEFAULT_SETTINGS.rules[0].contentSource;
|
|
rule.footerText = originalRule.footerText || ''; // Retain name for compatibility
|
|
rule.renderLocation = originalRule.renderLocation || DEFAULT_SETTINGS.rules[0].renderLocation;
|
|
|
|
if (rule.contentSource === ContentSource.File) {
|
|
rule.footerFilePath = originalRule.footerFilePath || ''; // Retain name for compatibility
|
|
} else { // ContentSource.Text
|
|
delete rule.footerFilePath;
|
|
}
|
|
|
|
// Normalize sidebar-specific fields
|
|
if (rule.renderLocation === RenderLocation.Sidebar) {
|
|
rule.showInSeparateTab = typeof originalRule.showInSeparateTab === 'boolean' ? originalRule.showInSeparateTab : false;
|
|
rule.sidebarTabName = originalRule.sidebarTabName || '';
|
|
} else {
|
|
delete rule.showInSeparateTab;
|
|
delete rule.sidebarTabName;
|
|
}
|
|
|
|
// Normalize positioning fields based on render location
|
|
if (rule.renderLocation === RenderLocation.Header) {
|
|
rule.renderAboveProperties = typeof originalRule.renderAboveProperties === 'boolean' ? originalRule.renderAboveProperties : false;
|
|
delete rule.renderAboveBacklinks;
|
|
delete rule.sectionHeaderText;
|
|
delete rule.sectionHeaderLevel;
|
|
delete rule.sectionHeaderPlacement;
|
|
} else if (rule.renderLocation === RenderLocation.Footer) {
|
|
rule.renderAboveBacklinks = typeof originalRule.renderAboveBacklinks === 'boolean' ? originalRule.renderAboveBacklinks : false;
|
|
delete rule.renderAboveProperties;
|
|
delete rule.sectionHeaderText;
|
|
delete rule.sectionHeaderLevel;
|
|
delete rule.sectionHeaderPlacement;
|
|
} else if (rule.renderLocation === RenderLocation.SectionHeader) {
|
|
rule.sectionHeaderText = originalRule.sectionHeaderText || '';
|
|
rule.sectionHeaderLevel = /^h[1-6]$/.test(originalRule.sectionHeaderLevel || '') ? originalRule.sectionHeaderLevel : 'h2';
|
|
rule.sectionHeaderPlacement = originalRule.sectionHeaderPlacement === 'bottom' ? 'bottom' : 'top';
|
|
delete rule.renderAboveProperties;
|
|
delete rule.renderAboveBacklinks;
|
|
} else {
|
|
delete rule.renderAboveProperties;
|
|
delete rule.renderAboveBacklinks;
|
|
delete rule.sectionHeaderText;
|
|
delete rule.sectionHeaderLevel;
|
|
delete rule.sectionHeaderPlacement;
|
|
}
|
|
|
|
// Normalize popover visibility setting
|
|
rule.showInPopover = typeof originalRule.showInPopover === 'boolean' ? originalRule.showInPopover : true;
|
|
rule.showInEmbed = typeof originalRule.showInEmbed === 'boolean' ? originalRule.showInEmbed : true;
|
|
rule.showInCanvas = typeof originalRule.showInCanvas === 'boolean' ? originalRule.showInCanvas : true;
|
|
}
|
|
|
|
/**
|
|
* Saves the current plugin settings to storage and triggers a view refresh.
|
|
*/
|
|
async saveSettings() {
|
|
// Ensure all rules are normalized before saving
|
|
this.settings.rules.forEach(rule => this.normalizeRule(rule));
|
|
await this.saveData(this.settings);
|
|
this.registerDynamicViews(); // Re-register views in case names/rules changed
|
|
this.handleActiveViewChange(); // Refresh views to apply changes
|
|
}
|
|
|
|
async activateView(viewId: string) {
|
|
this.app.workspace.detachLeavesOfType(viewId);
|
|
|
|
const leaf = this.app.workspace.getRightLeaf(true);
|
|
if (leaf) {
|
|
await leaf.setViewState({
|
|
type: viewId,
|
|
active: true,
|
|
});
|
|
|
|
this.app.workspace.revealLeaf(leaf);
|
|
}
|
|
}
|
|
|
|
private activateAllSidebarViews() {
|
|
this.activateView(VIRTUAL_CONTENT_VIEW_TYPE);
|
|
this.settings.rules.forEach((rule, index) => {
|
|
if (rule.enabled && rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) {
|
|
this.activateView(this.getSeparateViewId(index));
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateAllSidebarViews() {
|
|
const leaves = this.app.workspace.getLeavesOfType(VIRTUAL_CONTENT_VIEW_TYPE);
|
|
for (const leaf of leaves) {
|
|
if (leaf.view instanceof VirtualContentView) {
|
|
leaf.view.update();
|
|
}
|
|
}
|
|
this.settings.rules.forEach((rule, index) => {
|
|
if (rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) {
|
|
const viewId = this.getSeparateViewId(index);
|
|
const separateLeaves = this.app.workspace.getLeavesOfType(viewId);
|
|
for (const leaf of separateLeaves) {
|
|
if (leaf.view instanceof VirtualContentView) {
|
|
leaf.view.update();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public getLastSidebarContent(): { content: string, sourcePath: string } | null {
|
|
return this.lastSidebarContent;
|
|
}
|
|
|
|
public getSeparateTabContent(viewId: string): { content: string, sourcePath: string } | null {
|
|
return this.lastSeparateTabContents.get(viewId) || null;
|
|
}
|
|
|
|
private getSeparateViewId(ruleIndex: number): string {
|
|
return `${VIRTUAL_CONTENT_SEPARATE_VIEW_TYPE_PREFIX}${ruleIndex}`;
|
|
}
|
|
|
|
private registerDynamicViews() {
|
|
this.settings.rules.forEach((rule, index) => {
|
|
if (rule.renderLocation === RenderLocation.Sidebar && rule.showInSeparateTab) {
|
|
const viewId = this.getSeparateViewId(index);
|
|
const tabName = rule.sidebarTabName?.trim() ? `Virtual Content: ${rule.sidebarTabName}` : `Virtual Content: Rule ${index + 1}`;
|
|
|
|
try {
|
|
this.registerView(
|
|
viewId,
|
|
(leaf) => new VirtualContentView(leaf, this, viewId, tabName, () => this.getSeparateTabContent(viewId))
|
|
);
|
|
} catch (error) {
|
|
// Calling `this.registerView` when the view already exists, the following error occurs.
|
|
// This error doesn't need special handling, but if it is left unhandled, the Settings UI will not update properly.
|
|
|
|
// console.error(error) // `Error: Attempting to register an existing view type "virtual-content-separate-view-1"`
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Settings Tab Class ---
|
|
|
|
type RuleEditorProviders = {
|
|
getAvailableFolderPaths: () => Set<string>;
|
|
getAvailableTags: () => Set<string>;
|
|
getAvailableMarkdownFilePaths: () => Set<string>;
|
|
getAvailablePropertyNames: () => Set<string>;
|
|
};
|
|
|
|
type RuleEditorOptions = {
|
|
title: string;
|
|
onSave: (rule: Rule) => Promise<void> | void;
|
|
onCancel?: () => void;
|
|
providers: RuleEditorProviders;
|
|
};
|
|
|
|
class RuleEditorModal extends Modal {
|
|
private workingRule: Rule;
|
|
private didSave = false;
|
|
|
|
constructor(app: App, private plugin: VirtualFooterPlugin, rule: Rule, private options: RuleEditorOptions) {
|
|
super(app);
|
|
this.workingRule = RuleEditorModal.cloneRule(rule);
|
|
this.setTitle(options.title);
|
|
}
|
|
|
|
onOpen(): void {
|
|
this.modalEl.addClass('mod-lg');
|
|
this.render();
|
|
}
|
|
|
|
onClose(): void {
|
|
this.contentEl.empty();
|
|
if (!this.didSave) {
|
|
this.options.onCancel?.();
|
|
}
|
|
}
|
|
|
|
private static cloneRule(rule: Rule): Rule {
|
|
return JSON.parse(JSON.stringify(rule));
|
|
}
|
|
|
|
private render(): void {
|
|
this.contentEl.empty();
|
|
this.renderRuleEditor(this.contentEl);
|
|
this.renderButtons(this.contentEl);
|
|
}
|
|
|
|
private renderButtons(containerEl: HTMLElement): void {
|
|
const buttonsEl = containerEl.createDiv({ cls: 'modal-button-container' });
|
|
new ButtonComponent(buttonsEl)
|
|
.setButtonText('Save')
|
|
.setCta()
|
|
.onClick(() => void this.handleSave());
|
|
new ButtonComponent(buttonsEl)
|
|
.setButtonText('Cancel')
|
|
.onClick(() => this.close());
|
|
}
|
|
|
|
private async handleSave(): Promise<void> {
|
|
this.didSave = true;
|
|
this.plugin.normalizeRule(this.workingRule);
|
|
await this.options.onSave(this.workingRule);
|
|
this.close();
|
|
}
|
|
|
|
private renderRuleEditor(containerEl: HTMLElement): void {
|
|
new Setting(containerEl)
|
|
.setName('Rule name')
|
|
.setDesc('A descriptive name for this rule.')
|
|
.addText(text => text
|
|
.setPlaceholder('e.g., Project notes footer')
|
|
.setValue(this.workingRule.name || '')
|
|
.onChange((value) => {
|
|
this.workingRule.name = value;
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Enabled')
|
|
.setDesc('If disabled, this rule will not be applied.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.enabled ?? true)
|
|
.onChange((value) => {
|
|
this.workingRule.enabled = value;
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Rule type')
|
|
.setDesc('Apply this rule based on folder, tag, property, or a combination.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption(RuleType.Folder, 'Folder')
|
|
.addOption(RuleType.Tag, 'Tag')
|
|
.addOption(RuleType.Property, 'Property')
|
|
.addOption(RuleType.Multi, 'Multi-condition')
|
|
.addOption(RuleType.Dataview, 'Dataview')
|
|
.setValue(this.workingRule.type)
|
|
.onChange((value: string) => {
|
|
this.workingRule.type = value as RuleType;
|
|
if (this.workingRule.type === RuleType.Multi && !this.workingRule.conditions) {
|
|
this.workingRule.conditions = [];
|
|
}
|
|
this.plugin.normalizeRule(this.workingRule);
|
|
this.render();
|
|
}));
|
|
|
|
this.renderTypeSpecificControls(containerEl);
|
|
|
|
new Setting(containerEl)
|
|
.setName('Content source')
|
|
.setDesc('Where to get the content from: direct text input or a separate Markdown file.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption(ContentSource.Text, 'Direct text')
|
|
.addOption(ContentSource.File, 'Markdown file')
|
|
.setValue(this.workingRule.contentSource || ContentSource.Text)
|
|
.onChange((value: string) => {
|
|
this.workingRule.contentSource = value as ContentSource;
|
|
this.plugin.normalizeRule(this.workingRule);
|
|
this.render();
|
|
}));
|
|
|
|
if (this.workingRule.contentSource === ContentSource.File) {
|
|
new Setting(containerEl)
|
|
.setName('Content file path')
|
|
.setDesc('Path to the .md file to use as content (e.g., "templates/common-footer.md").')
|
|
.addText(text => {
|
|
text.setPlaceholder('e.g., templates/common-footer.md')
|
|
.setValue(this.workingRule.footerFilePath || '')
|
|
.onChange((value) => {
|
|
this.workingRule.footerFilePath = value;
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailableMarkdownFilePaths(), (selectedPath) => {
|
|
this.workingRule.footerFilePath = selectedPath;
|
|
text.setValue(selectedPath);
|
|
}, this.plugin.app);
|
|
});
|
|
} else {
|
|
new Setting(containerEl)
|
|
.setName('Content text')
|
|
.setDesc('Markdown text to display. This will be rendered.')
|
|
.addTextArea(text => text
|
|
.setPlaceholder('Enter your markdown content here...')
|
|
.setValue(this.workingRule.footerText || '')
|
|
.onChange((value) => {
|
|
this.workingRule.footerText = value;
|
|
}));
|
|
}
|
|
|
|
new Setting(containerEl)
|
|
.setName('Render location')
|
|
.setDesc('Choose whether this rule renders its content in the note header, footer, a selected section, or a dedicated sidebar tab.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption(RenderLocation.Footer, 'Footer')
|
|
.addOption(RenderLocation.Header, 'Header')
|
|
.addOption(RenderLocation.SectionHeader, 'Section header')
|
|
.addOption(RenderLocation.Sidebar, 'Sidebar')
|
|
.setValue(this.workingRule.renderLocation || RenderLocation.Footer)
|
|
.onChange((value: string) => {
|
|
this.workingRule.renderLocation = value as RenderLocation;
|
|
this.plugin.normalizeRule(this.workingRule);
|
|
this.render();
|
|
}));
|
|
|
|
this.renderLocationSpecificControls(containerEl);
|
|
|
|
new Setting(containerEl)
|
|
.setName('Show in popover views')
|
|
.setDesc('If enabled, this rule\'s content will be shown when viewing notes in hover popovers.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.showInPopover !== undefined ? this.workingRule.showInPopover : true)
|
|
.onChange((value) => {
|
|
this.workingRule.showInPopover = value;
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Show in embedded notes')
|
|
.setDesc('If enabled, this rule\'s content will be shown inside embedded notes such as ![[Link]].')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.showInEmbed !== undefined ? this.workingRule.showInEmbed : true)
|
|
.onChange((value) => {
|
|
this.workingRule.showInEmbed = value;
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Show in canvas cards')
|
|
.setDesc('If enabled, this rule\'s content will be shown inside note cards on Canvas.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.showInCanvas !== undefined ? this.workingRule.showInCanvas : true)
|
|
.onChange((value) => {
|
|
this.workingRule.showInCanvas = value;
|
|
}));
|
|
}
|
|
|
|
private renderTypeSpecificControls(containerEl: HTMLElement): void {
|
|
if (this.workingRule.type === RuleType.Folder) {
|
|
new Setting(containerEl)
|
|
.setName('Condition')
|
|
.setDesc('Choose whether this condition should be met or not met.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('is', 'is')
|
|
.addOption('not', 'not')
|
|
.setValue(this.workingRule.negated ? 'not' : 'is')
|
|
.onChange((value: string) => {
|
|
this.workingRule.negated = value === 'not';
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Folder path')
|
|
.setDesc('Leave empty for all files, "/" for root, or "FolderName/" for a specific folder.')
|
|
.addText(text => {
|
|
text.setPlaceholder('e.g., Meetings/, /, or empty for all')
|
|
.setValue(this.workingRule.path || '')
|
|
.onChange((value) => {
|
|
this.workingRule.path = value;
|
|
this.plugin.normalizeRule(this.workingRule);
|
|
this.render();
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailableFolderPaths(), (selectedPath) => {
|
|
this.workingRule.path = selectedPath;
|
|
this.plugin.normalizeRule(this.workingRule);
|
|
text.setValue(selectedPath);
|
|
this.render();
|
|
}, this.plugin.app);
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName('Include subfolders (recursive)')
|
|
.setDesc('If enabled, the rule applies to files in subfolders.')
|
|
.addToggle(toggle => {
|
|
toggle.setValue(this.workingRule.recursive ?? true)
|
|
.onChange((value) => {
|
|
this.workingRule.recursive = value;
|
|
});
|
|
if (this.workingRule.path === '') {
|
|
toggle.setDisabled(true);
|
|
}
|
|
});
|
|
} else if (this.workingRule.type === RuleType.Tag) {
|
|
new Setting(containerEl)
|
|
.setName('Condition')
|
|
.setDesc('Choose whether this condition should be met or not met.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('is', 'is')
|
|
.addOption('not', 'not')
|
|
.setValue(this.workingRule.negated ? 'not' : 'is')
|
|
.onChange((value: string) => {
|
|
this.workingRule.negated = value === 'not';
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Tag value')
|
|
.setDesc('Tag to match (without the # prefix).')
|
|
.addText(text => {
|
|
text.setPlaceholder('e.g., important or project/alpha')
|
|
.setValue(this.workingRule.tag || '')
|
|
.onChange((value) => {
|
|
this.workingRule.tag = value.startsWith('#') ? value.substring(1) : value;
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailableTags(), (selectedTag) => {
|
|
const normalizedTag = selectedTag.startsWith('#') ? selectedTag.substring(1) : selectedTag;
|
|
this.workingRule.tag = normalizedTag;
|
|
text.setValue(normalizedTag);
|
|
}, this.plugin.app);
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName('Include subtags')
|
|
.setDesc('If enabled, a rule for "tag" applies to "tag/subtag" values.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.includeSubtags ?? false)
|
|
.onChange((value) => {
|
|
this.workingRule.includeSubtags = value;
|
|
}));
|
|
} else if (this.workingRule.type === RuleType.Property) {
|
|
new Setting(containerEl)
|
|
.setName('Condition')
|
|
.setDesc('Choose whether this condition should be met or not met.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('is', 'is')
|
|
.addOption('not', 'not')
|
|
.setValue(this.workingRule.negated ? 'not' : 'is')
|
|
.onChange((value: string) => {
|
|
this.workingRule.negated = value === 'not';
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Property name')
|
|
.setDesc('The name of the Obsidian property to match.')
|
|
.addText(text => {
|
|
text.setPlaceholder('e.g., status, type, author')
|
|
.setValue(this.workingRule.propertyName || '')
|
|
.onChange((value) => {
|
|
this.workingRule.propertyName = value;
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailablePropertyNames(), (selectedName) => {
|
|
this.workingRule.propertyName = selectedName;
|
|
text.setValue(selectedName);
|
|
}, this.plugin.app);
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName('Property value')
|
|
.setDesc('Leave empty to match any file that has the property.')
|
|
.addText(text => text
|
|
.setPlaceholder('e.g., complete, article, John Doe')
|
|
.setValue(this.workingRule.propertyValue || '')
|
|
.onChange((value) => {
|
|
this.workingRule.propertyValue = value;
|
|
}));
|
|
} else if (this.workingRule.type === RuleType.Multi) {
|
|
this.renderMultiConditionControls(containerEl);
|
|
} else if (this.workingRule.type === RuleType.Dataview) {
|
|
new Setting(containerEl)
|
|
.setName('Condition')
|
|
.setDesc('Choose whether this condition should be met or not met.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('is', 'is')
|
|
.addOption('not', 'not')
|
|
.setValue(this.workingRule.negated ? 'not' : 'is')
|
|
.onChange((value: string) => {
|
|
this.workingRule.negated = value === 'not';
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Dataview query')
|
|
.setDesc('Enter a Dataview LIST query to match notes where this rule should apply.')
|
|
.addTextArea(text => text
|
|
.setPlaceholder('LIST FROM "References/Authors" WHERE startswith(file.name, "Example")')
|
|
.setValue(this.workingRule.dataviewQuery || '')
|
|
.onChange((value) => {
|
|
this.workingRule.dataviewQuery = value;
|
|
}));
|
|
|
|
const infoDiv = containerEl.createDiv('dataview-info');
|
|
infoDiv.createEl('p', {
|
|
text: 'Note: The Dataview plugin must be installed for this rule type to work.',
|
|
cls: 'setting-item-description',
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderMultiConditionControls(containerEl: HTMLElement): void {
|
|
new Setting(containerEl)
|
|
.setName('Condition logic')
|
|
.setDesc('Choose whether any condition or all conditions must be met.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('any', 'Any condition')
|
|
.addOption('all', 'All conditions')
|
|
.setValue(this.workingRule.multiConditionLogic || 'any')
|
|
.onChange((value) => {
|
|
this.workingRule.multiConditionLogic = value as 'any' | 'all';
|
|
}));
|
|
|
|
const conditionsContainer = containerEl.createDiv('virtual-footer-conditions-container');
|
|
const conditions = this.workingRule.conditions ?? [];
|
|
conditions.forEach((condition, index) => {
|
|
this.renderSubConditionControls(condition, index, conditionsContainer);
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.addButton(button => button
|
|
.setButtonText('Add condition')
|
|
.setCta()
|
|
.onClick(() => {
|
|
this.workingRule.conditions = this.workingRule.conditions || [];
|
|
this.workingRule.conditions.push({ type: 'folder', path: '', recursive: true, negated: false });
|
|
this.render();
|
|
}));
|
|
}
|
|
|
|
private renderSubConditionControls(condition: SubCondition, index: number, containerEl: HTMLElement): void {
|
|
const conditionDiv = containerEl.createDiv('virtual-footer-sub-condition-item');
|
|
const setting = new Setting(conditionDiv)
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('is', 'is')
|
|
.addOption('not', 'not')
|
|
.setValue(condition.negated ? 'not' : 'is')
|
|
.onChange((value: string) => {
|
|
condition.negated = value === 'not';
|
|
}))
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('folder', 'Folder')
|
|
.addOption('tag', 'Tag')
|
|
.addOption('property', 'Property')
|
|
.setValue(condition.type)
|
|
.onChange((value: string) => {
|
|
condition.type = value as 'folder' | 'tag' | 'property';
|
|
delete condition.path;
|
|
delete condition.recursive;
|
|
delete condition.tag;
|
|
delete condition.includeSubtags;
|
|
delete condition.propertyName;
|
|
delete condition.propertyValue;
|
|
this.render();
|
|
}));
|
|
|
|
if (condition.type === 'folder') {
|
|
setting.addText(text => {
|
|
text.setPlaceholder('Folder path')
|
|
.setValue(condition.path || '')
|
|
.onChange((value) => {
|
|
condition.path = value;
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailableFolderPaths(), (selected) => {
|
|
condition.path = selected;
|
|
text.setValue(selected);
|
|
}, this.plugin.app);
|
|
});
|
|
setting.addToggle(toggle => toggle
|
|
.setTooltip('Include subfolders')
|
|
.setValue(condition.recursive ?? true)
|
|
.onChange((value) => {
|
|
condition.recursive = value;
|
|
}));
|
|
} else if (condition.type === 'tag') {
|
|
setting.addText(text => {
|
|
text.setPlaceholder('Tag value (no #)')
|
|
.setValue(condition.tag || '')
|
|
.onChange((value) => {
|
|
condition.tag = value.startsWith('#') ? value.substring(1) : value;
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailableTags(), (selected) => {
|
|
const normalized = selected.startsWith('#') ? selected.substring(1) : selected;
|
|
condition.tag = normalized;
|
|
text.setValue(normalized);
|
|
}, this.plugin.app);
|
|
});
|
|
setting.addToggle(toggle => toggle
|
|
.setTooltip('Include subtags')
|
|
.setValue(condition.includeSubtags ?? false)
|
|
.onChange((value) => {
|
|
condition.includeSubtags = value;
|
|
}));
|
|
} else if (condition.type === 'property') {
|
|
setting.addText(text => {
|
|
text.setPlaceholder('Property name')
|
|
.setValue(condition.propertyName || '')
|
|
.onChange((value) => {
|
|
condition.propertyName = value;
|
|
});
|
|
new MultiSuggest(text.inputEl, this.options.providers.getAvailablePropertyNames(), (selected) => {
|
|
condition.propertyName = selected;
|
|
text.setValue(selected);
|
|
}, this.plugin.app);
|
|
});
|
|
setting.addText(text => text
|
|
.setPlaceholder('Property value (optional)')
|
|
.setValue(condition.propertyValue || '')
|
|
.onChange((value) => {
|
|
condition.propertyValue = value;
|
|
}));
|
|
}
|
|
|
|
setting.addButton(button => button
|
|
.setIcon('trash')
|
|
.setTooltip('Delete condition')
|
|
.setWarning()
|
|
.onClick(() => {
|
|
this.workingRule.conditions?.splice(index, 1);
|
|
this.render();
|
|
}));
|
|
}
|
|
|
|
private renderLocationSpecificControls(containerEl: HTMLElement): void {
|
|
if (this.workingRule.renderLocation === RenderLocation.Sidebar) {
|
|
new Setting(containerEl)
|
|
.setName('Show in separate tab')
|
|
.setDesc('If enabled, this content appears in its own sidebar tab.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.showInSeparateTab ?? false)
|
|
.onChange((value) => {
|
|
this.workingRule.showInSeparateTab = value;
|
|
this.render();
|
|
}));
|
|
|
|
if (this.workingRule.showInSeparateTab) {
|
|
new Setting(containerEl)
|
|
.setName('Sidebar tab name')
|
|
.setDesc('If empty, a default name is used.')
|
|
.addText(text => text
|
|
.setPlaceholder('e.g., Related notes')
|
|
.setValue(this.workingRule.sidebarTabName || '')
|
|
.onChange((value) => {
|
|
this.workingRule.sidebarTabName = value;
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (this.workingRule.renderLocation === RenderLocation.Header) {
|
|
new Setting(containerEl)
|
|
.setName('Render above properties')
|
|
.setDesc('If enabled, header content renders above frontmatter properties.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.renderAboveProperties || false)
|
|
.onChange((value) => {
|
|
this.workingRule.renderAboveProperties = value;
|
|
}));
|
|
}
|
|
|
|
if (this.workingRule.renderLocation === RenderLocation.SectionHeader) {
|
|
new Setting(containerEl)
|
|
.setName('Header text')
|
|
.setDesc('The exact heading text to target, without leading # characters.')
|
|
.addText(text => text
|
|
.setPlaceholder('e.g., Tasks')
|
|
.setValue(this.workingRule.sectionHeaderText || '')
|
|
.onChange((value) => {
|
|
this.workingRule.sectionHeaderText = value;
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Header type')
|
|
.setDesc('Choose which heading level should be matched.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('h1', 'H1')
|
|
.addOption('h2', 'H2')
|
|
.addOption('h3', 'H3')
|
|
.addOption('h4', 'H4')
|
|
.addOption('h5', 'H5')
|
|
.addOption('h6', 'H6')
|
|
.setValue(this.workingRule.sectionHeaderLevel || 'h2')
|
|
.onChange((value: string) => {
|
|
this.workingRule.sectionHeaderLevel = value;
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Section placement')
|
|
.setDesc('Choose whether content appears at the top or bottom of the section.')
|
|
.addDropdown(dropdown => dropdown
|
|
.addOption('top', 'Top of section')
|
|
.addOption('bottom', 'Bottom of section')
|
|
.setValue(this.workingRule.sectionHeaderPlacement || 'top')
|
|
.onChange((value: string) => {
|
|
this.workingRule.sectionHeaderPlacement = value as SectionHeaderPlacement;
|
|
}));
|
|
}
|
|
|
|
if (this.workingRule.renderLocation === RenderLocation.Footer) {
|
|
new Setting(containerEl)
|
|
.setName('Render above backlinks')
|
|
.setDesc('If enabled, footer content renders above embedded backlinks.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.workingRule.renderAboveBacklinks || false)
|
|
.onChange((value) => {
|
|
this.workingRule.renderAboveBacklinks = value;
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manages the settings tab UI for the VirtualFooter plugin.
|
|
* Allows users to configure rules for dynamic content injection.
|
|
*/
|
|
class VirtualFooterSettingTab extends PluginSettingTab {
|
|
// Caches for suggestion lists to improve performance
|
|
private allFolderPathsCache: Set<string> | null = null;
|
|
private allTagsCache: Set<string> | null = null;
|
|
private allMarkdownFilePathsCache: Set<string> | null = null;
|
|
private allPropertyNamesCache: Set<string> | null = null;
|
|
constructor(app: App, private plugin: VirtualFooterPlugin) {
|
|
super(app, plugin);
|
|
}
|
|
|
|
/**
|
|
* Lazily gets and caches all unique folder paths in the vault.
|
|
* Includes special paths "" (all files) and "/" (root).
|
|
* @returns A set of available folder paths.
|
|
*/
|
|
private getAvailableFolderPaths(): Set<string> {
|
|
if (this.allFolderPathsCache) return this.allFolderPathsCache;
|
|
|
|
const paths = new Set<string>(['/', '']); // Special paths
|
|
this.app.vault.getAllLoadedFiles().forEach(file => {
|
|
if (file.parent) { // Has a parent folder
|
|
const parentPath = file.parent.isRoot() ? '/' : (file.parent.path.endsWith('/') ? file.parent.path : file.parent.path + '/');
|
|
if (parentPath !== '/') paths.add(parentPath); // Add parent path, ensuring trailing slash
|
|
}
|
|
// If the file itself is a folder (Obsidian's TFolder)
|
|
if ('children' in file && file.path !== '/') { // 'children' indicates a TFolder
|
|
const folderPath = file.path.endsWith('/') ? file.path : file.path + '/';
|
|
paths.add(folderPath); // Add folder path, ensuring trailing slash
|
|
}
|
|
});
|
|
this.allFolderPathsCache = paths;
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* Lazily gets and caches all unique tags (without '#') present in Markdown files.
|
|
* @returns A set of available tags.
|
|
*/
|
|
private getAvailableTags(): Set<string> {
|
|
if (this.allTagsCache) return this.allTagsCache;
|
|
|
|
const collectedTags = new Set<string>();
|
|
this.app.vault.getMarkdownFiles().forEach(file => {
|
|
const fileCache = this.app.metadataCache.getFileCache(file);
|
|
if (fileCache) {
|
|
const tagsInFile = getAllTags(fileCache); // Returns tags with '#'
|
|
tagsInFile?.forEach(tag => {
|
|
collectedTags.add(tag.substring(1)); // Store without '#'
|
|
});
|
|
}
|
|
});
|
|
this.allTagsCache = collectedTags;
|
|
return collectedTags;
|
|
}
|
|
|
|
/**
|
|
* Lazily gets and caches all Markdown file paths in the vault.
|
|
* @returns A set of available Markdown file paths.
|
|
*/
|
|
private getAvailableMarkdownFilePaths(): Set<string> {
|
|
if (this.allMarkdownFilePathsCache) return this.allMarkdownFilePathsCache;
|
|
|
|
const paths = new Set<string>();
|
|
this.app.vault.getMarkdownFiles().forEach(file => {
|
|
paths.add(file.path);
|
|
});
|
|
this.allMarkdownFilePathsCache = paths;
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* Lazily gets and caches all unique frontmatter property keys from Markdown files.
|
|
* @returns A set of available property names.
|
|
*/
|
|
private getAvailablePropertyNames(): Set<string> {
|
|
if (this.allPropertyNamesCache) return this.allPropertyNamesCache;
|
|
|
|
// @ts-ignore - getFrontmatterPropertyKeys is an undocumented API, but widely used.
|
|
const keys = this.app.metadataCache.getFrontmatterPropertyKeys?.() || [];
|
|
this.allPropertyNamesCache = new Set(keys);
|
|
return this.allPropertyNamesCache;
|
|
}
|
|
|
|
getSettingDefinitions() {
|
|
this.clearSuggestionCaches();
|
|
const rules = this.plugin.settings.rules ?? [];
|
|
return [
|
|
{
|
|
name: 'Render in source mode',
|
|
desc: 'If enabled, virtual content will be rendered in source mode. By default, content only appears in Live Preview and Reading modes.',
|
|
control: { type: 'toggle', key: 'renderInSourceMode' },
|
|
},
|
|
{
|
|
name: 'Refresh on focus change',
|
|
desc: 'If enabled, virtual content will refresh when switching files. This can cause a slight flicker but ensures immediate updates.',
|
|
control: { type: 'toggle', key: 'refreshOnFileOpen' },
|
|
},
|
|
{
|
|
name: 'Refresh on metadata change',
|
|
desc: 'If enabled, virtual content will refresh when the current note\'s metadata changes.',
|
|
control: { type: 'toggle', key: 'refreshOnMetadataChange' },
|
|
},
|
|
{
|
|
name: 'Smart property links',
|
|
desc: 'If enabled, property conditions that look like links match against the resolved file.',
|
|
control: { type: 'toggle', key: 'smartPropertyLinks' },
|
|
},
|
|
{
|
|
name: 'Embed rendering',
|
|
desc: 'If enabled, virtual content will render inside embedded notes such as ![[Link]].',
|
|
control: { type: 'toggle', key: 'enableEmbedRendering' },
|
|
},
|
|
{
|
|
name: 'Canvas rendering',
|
|
desc: 'If enabled, virtual content can be rendered inside Canvas note cards. Restart Obsidian after changing this setting.',
|
|
control: { type: 'toggle', key: 'enableCanvasRendering' },
|
|
},
|
|
{
|
|
type: 'list',
|
|
heading: 'Rules',
|
|
emptyState: 'No rules yet.',
|
|
addItem: {
|
|
name: 'Add rule',
|
|
action: () => this.openNewRuleModal(),
|
|
},
|
|
onReorder: async (oldIndex: number, newIndex: number) => {
|
|
const rulesList = this.plugin.settings.rules;
|
|
const [moved] = rulesList.splice(oldIndex, 1);
|
|
rulesList.splice(newIndex, 0, moved);
|
|
await this.plugin.saveSettings();
|
|
},
|
|
onDelete: async (idx: number) => {
|
|
this.plugin.settings.rules.splice(idx, 1);
|
|
await this.plugin.saveSettings();
|
|
this.refreshSettingsUi();
|
|
},
|
|
items: rules.map((rule, index) => ({
|
|
name: this.getRuleDisplayName(rule, index),
|
|
desc: this.getRuleSummary(rule),
|
|
searchable: false,
|
|
render: (setting: Setting) => {
|
|
setting.addButton(button => button
|
|
.setButtonText('Edit')
|
|
.setCta()
|
|
.onClick(() => this.openEditRuleModal(index)));
|
|
setting.addButton(button => button
|
|
.setButtonText('Duplicate')
|
|
.onClick(() => this.duplicateRule(index)));
|
|
},
|
|
})),
|
|
},
|
|
{
|
|
type: 'group',
|
|
heading: 'Developer',
|
|
items: [
|
|
{
|
|
name: 'Debug embed/canvas rendering',
|
|
desc: 'If enabled, logs embed/canvas path resolution details to the developer console for troubleshooting.',
|
|
control: { type: 'toggle', key: 'debugEmbedCanvas' },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Renders the settings tab UI.
|
|
* This method is called by Obsidian on versions before 1.13.0.
|
|
*/
|
|
display(): void {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
this.clearSuggestionCaches();
|
|
|
|
new Setting(containerEl)
|
|
.setName('Render in source mode')
|
|
.setDesc('If enabled, virtual content will be rendered in source mode. By default, content only appears in Live Preview and Reading modes.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.renderInSourceMode ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.renderInSourceMode = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Refresh on focus change')
|
|
.setDesc('If enabled, virtual content will refresh when switching files. This can cause a slight flicker but ensures immediate updates.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.refreshOnFileOpen ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.refreshOnFileOpen = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Refresh on metadata change')
|
|
.setDesc('If enabled, virtual content will refresh when the current note\'s metadata changes.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.refreshOnMetadataChange ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.refreshOnMetadataChange = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Smart property links')
|
|
.setDesc('If enabled, property conditions that look like links match against the resolved file.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.smartPropertyLinks ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.smartPropertyLinks = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Embed rendering')
|
|
.setDesc('If enabled, virtual content will render inside embedded notes such as ![[Link]].')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.enableEmbedRendering ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.enableEmbedRendering = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Canvas rendering')
|
|
.setDesc('If enabled, virtual content can be rendered inside Canvas note cards. Restart Obsidian after changing this setting.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.enableCanvasRendering ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.enableCanvasRendering = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
containerEl.createEl('h3', { text: 'Rules' });
|
|
const rules = this.plugin.settings.rules ?? [];
|
|
rules.forEach((rule, index) => {
|
|
const row = new Setting(containerEl)
|
|
.setName(this.getRuleDisplayName(rule, index))
|
|
.setDesc(this.getRuleSummary(rule));
|
|
|
|
row.addButton(button => button
|
|
.setButtonText('Edit')
|
|
.setCta()
|
|
.onClick(() => this.openEditRuleModal(index)));
|
|
row.addButton(button => button
|
|
.setButtonText('Duplicate')
|
|
.onClick(() => this.duplicateRule(index)));
|
|
row.addButton(button => button
|
|
.setIcon('arrow-up')
|
|
.setTooltip('Move rule up')
|
|
.setDisabled(index === 0)
|
|
.onClick(() => this.moveRule(index, index - 1)));
|
|
row.addButton(button => button
|
|
.setIcon('arrow-down')
|
|
.setTooltip('Move rule down')
|
|
.setDisabled(index === rules.length - 1)
|
|
.onClick(() => this.moveRule(index, index + 1)));
|
|
row.addButton(button => button
|
|
.setButtonText('Delete')
|
|
.setWarning()
|
|
.onClick(() => this.deleteRule(index)));
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.addButton(button => button
|
|
.setButtonText('Add rule')
|
|
.setCta()
|
|
.onClick(() => this.openNewRuleModal()));
|
|
|
|
containerEl.createEl('h3', { text: 'Developer' });
|
|
new Setting(containerEl)
|
|
.setName('Debug embed/canvas rendering')
|
|
.setDesc('If enabled, logs embed/canvas path resolution details to the developer console for troubleshooting.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.debugEmbedCanvas ?? false)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.debugEmbedCanvas = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
}
|
|
|
|
private clearSuggestionCaches(): void {
|
|
this.allFolderPathsCache = null;
|
|
this.allTagsCache = null;
|
|
this.allMarkdownFilePathsCache = null;
|
|
this.allPropertyNamesCache = null;
|
|
}
|
|
|
|
private refreshSettingsUi(): void {
|
|
const updater = (this as unknown as { update?: () => void }).update;
|
|
if (typeof updater === 'function') {
|
|
updater.call(this);
|
|
} else {
|
|
this.display();
|
|
}
|
|
}
|
|
|
|
private createRuleTemplate(): Rule {
|
|
const newRule = JSON.parse(JSON.stringify(DEFAULT_SETTINGS.rules[0]));
|
|
this.plugin.normalizeRule(newRule);
|
|
return newRule;
|
|
}
|
|
|
|
private openNewRuleModal(): void {
|
|
const newRule = this.createRuleTemplate();
|
|
this.openRuleModal('Add rule', newRule, async (rule) => {
|
|
this.plugin.settings.rules.push(rule);
|
|
await this.plugin.saveSettings();
|
|
this.refreshSettingsUi();
|
|
});
|
|
}
|
|
|
|
private openEditRuleModal(index: number): void {
|
|
const rule = this.plugin.settings.rules[index];
|
|
if (!rule) return;
|
|
this.openRuleModal('Edit rule', rule, async (updatedRule) => {
|
|
this.plugin.settings.rules[index] = updatedRule;
|
|
await this.plugin.saveSettings();
|
|
this.refreshSettingsUi();
|
|
});
|
|
}
|
|
|
|
private openRuleModal(title: string, rule: Rule, onSave: (rule: Rule) => Promise<void>): void {
|
|
const modal = new RuleEditorModal(this.app, this.plugin, rule, {
|
|
title,
|
|
onSave,
|
|
providers: {
|
|
getAvailableFolderPaths: () => this.getAvailableFolderPaths(),
|
|
getAvailableTags: () => this.getAvailableTags(),
|
|
getAvailableMarkdownFilePaths: () => this.getAvailableMarkdownFilePaths(),
|
|
getAvailablePropertyNames: () => this.getAvailablePropertyNames(),
|
|
},
|
|
});
|
|
modal.open();
|
|
}
|
|
|
|
private duplicateRule(index: number): void {
|
|
const rule = this.plugin.settings.rules[index];
|
|
if (!rule) return;
|
|
const duplicate = JSON.parse(JSON.stringify(rule)) as Rule;
|
|
if (duplicate.name) {
|
|
duplicate.name = `${duplicate.name} copy`;
|
|
}
|
|
this.plugin.normalizeRule(duplicate);
|
|
this.plugin.settings.rules.splice(index + 1, 0, duplicate);
|
|
void this.plugin.saveSettings().then(() => this.refreshSettingsUi());
|
|
}
|
|
|
|
private async moveRule(fromIndex: number, toIndex: number): Promise<void> {
|
|
const rules = this.plugin.settings.rules;
|
|
if (toIndex < 0 || toIndex >= rules.length) return;
|
|
const [moved] = rules.splice(fromIndex, 1);
|
|
rules.splice(toIndex, 0, moved);
|
|
await this.plugin.saveSettings();
|
|
this.display();
|
|
}
|
|
|
|
private async deleteRule(index: number): Promise<void> {
|
|
this.plugin.settings.rules.splice(index, 1);
|
|
await this.plugin.saveSettings();
|
|
this.display();
|
|
}
|
|
|
|
private getRuleDisplayName(rule: Rule, index: number): string {
|
|
const name = rule.name?.trim();
|
|
return name ? name : `Rule ${index + 1}`;
|
|
}
|
|
|
|
private getRuleSummary(rule: Rule): string {
|
|
const parts: string[] = [];
|
|
if (rule.enabled === false) parts.push('Disabled');
|
|
parts.push(this.getRuleTypeSummary(rule));
|
|
parts.push(this.getRuleContentSummary(rule));
|
|
parts.push(this.getRuleLocationSummary(rule));
|
|
return parts.filter(Boolean).join(' | ');
|
|
}
|
|
|
|
private getRuleTypeSummary(rule: Rule): string {
|
|
switch (rule.type) {
|
|
case RuleType.Folder: {
|
|
const path = rule.path?.trim() || 'all files';
|
|
const condition = rule.negated ? 'not in' : 'in';
|
|
const recursive = rule.path === '' ? '' : (rule.recursive ? ' (recursive)' : '');
|
|
return `Folder ${condition} ${path}${recursive}`;
|
|
}
|
|
case RuleType.Tag: {
|
|
const tag = rule.tag?.trim() || 'tag';
|
|
const condition = rule.negated ? 'not tagged' : 'tagged';
|
|
const subtags = rule.includeSubtags ? ' (include subtags)' : '';
|
|
return `Tag ${condition} #${tag}${subtags}`;
|
|
}
|
|
case RuleType.Property: {
|
|
const name = rule.propertyName?.trim() || 'property';
|
|
const condition = rule.negated ? 'not' : 'has';
|
|
const value = rule.propertyValue?.trim() ? ` = ${rule.propertyValue.trim()}` : '';
|
|
return `Property ${condition} ${name}${value}`;
|
|
}
|
|
case RuleType.Multi: {
|
|
const logic = rule.multiConditionLogic === 'all' ? 'all' : 'any';
|
|
const count = rule.conditions?.length ?? 0;
|
|
return `Multi (${logic}, ${count} condition${count === 1 ? '' : 's'})`;
|
|
}
|
|
case RuleType.Dataview: {
|
|
return 'Dataview query';
|
|
}
|
|
default:
|
|
return 'Rule';
|
|
}
|
|
}
|
|
|
|
private getRuleContentSummary(rule: Rule): string {
|
|
if (rule.contentSource === ContentSource.File) {
|
|
return rule.footerFilePath?.trim() ? `File: ${rule.footerFilePath.trim()}` : 'File content';
|
|
}
|
|
return 'Text content';
|
|
}
|
|
|
|
private getRuleLocationSummary(rule: Rule): string {
|
|
switch (rule.renderLocation) {
|
|
case RenderLocation.Header:
|
|
return 'Header';
|
|
case RenderLocation.Footer:
|
|
return 'Footer';
|
|
case RenderLocation.Sidebar:
|
|
return rule.showInSeparateTab ? 'Sidebar (separate tab)' : 'Sidebar';
|
|
case RenderLocation.SectionHeader:
|
|
return 'Section header';
|
|
default:
|
|
return 'Location';
|
|
}
|
|
}
|
|
}
|