Files
Notes/.obsidian/plugins/rich-foot/main.js
T
2026-06-20 14:26:33 +02:00

1626 lines
65 KiB
JavaScript

/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.js
var main_exports = {};
__export(main_exports, {
default: () => main_default
});
module.exports = __toCommonJS(main_exports);
var import_obsidian4 = require("obsidian");
// src/modals.js
var import_obsidian = require("obsidian");
var ReleaseNotesModal = class extends import_obsidian.Modal {
constructor(app, plugin, version, releaseNotes2) {
super(app);
this.plugin = plugin;
this.version = version;
this.releaseNotes = releaseNotes2;
}
async onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: `Welcome to \u{1F9B6} Rich Foot v${this.version}` });
contentEl.createEl("p", {
text: "After each update you'll be prompted with the release notes. You can disable this in the plugin settings.",
cls: "release-notes-instructions"
});
const promotionalLinks = contentEl.createEl("div", { cls: "release-notes-promotional-links" });
const equilllabsLink = promotionalLinks.createEl("a", {
href: "https://www.equilllabs.com",
target: "_blank"
});
equilllabsLink.createEl("img", {
attr: {
height: "36",
style: "border:0px;height:36px;",
src: "https://raw.githubusercontent.com/jparkerweb/pixel-banner/refs/heads/main/img/equilllabs.png?raw=true",
border: "0",
alt: "eQuill-Labs"
}
});
const discordLink = promotionalLinks.createEl("a", {
href: "https://discord.gg/sp8AQQhMJ7",
target: "_blank"
});
discordLink.createEl("img", {
attr: {
height: "36",
style: "border:0px;height:36px;",
src: "https://raw.githubusercontent.com/jparkerweb/pixel-banner/refs/heads/main/img/discord.png?raw=true",
border: "0",
alt: "Discord"
}
});
const kofiLink = promotionalLinks.createEl("a", {
href: "https://ko-fi.com/Z8Z212UMBI",
target: "_blank"
});
kofiLink.createEl("img", {
attr: {
height: "36",
style: "border:0px;height:36px;",
src: "https://raw.githubusercontent.com/jparkerweb/pixel-banner/refs/heads/main/img/support.png?raw=true",
border: "0",
alt: "Buy Me a Coffee at ko-fi.com"
}
});
const notesContainer = contentEl.createDiv("release-notes-container");
await import_obsidian.MarkdownRenderer.render(
this.app,
this.releaseNotes,
notesContainer,
"",
this
);
contentEl.createEl("div", { cls: "release-notes-spacer" });
new import_obsidian.Setting(contentEl).addButton((btn) => btn.setButtonText("Close").onClick(() => this.close()));
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
};
// virtual-module:virtual:release-notes
var releaseNotes = "<h2>\u{1F680} New Feature</h2>\n<h3>v1.13.0</h3>\n<h4>\u2728 Adjustable Footer Width</h4>\n<ul>\n<li>Added a <strong>Footer Width</strong> setting to control how wide the footer is</li>\n<li>Choose <strong>Readable line length</strong> to lock the footer to Obsidian&#39;s readable line width, or <strong>Custom width</strong> to set a maximum width in pixels</li>\n<li>The footer now always caps at the width of the note, so it no longer causes horizontal scrolling on narrow screens (notably on mobile)</li>\n<li>Configure via Settings \u2192 Style Settings \u2192 <strong>Footer Width</strong></li>\n</ul>\n<h3>v1.12.0</h3>\n<h4>\u2728 Limit Links Shown</h4>\n<ul>\n<li>Added an option to limit the number of backlinks/outlinks shown in the footer</li>\n<li>Surplus links are hidden behind a <strong>Show More (X)</strong> button that expands them on click (and collapses again with <strong>Show Less</strong>)</li>\n<li>Helpful for notes with a large number of links that previously caused flickering and scroll-reset issues</li>\n<li>Configure via Settings \u2192 <strong>Limit Links Shown</strong> (toggle) and <strong>Links Limit</strong> (default 10)</li>\n</ul>\n<h2>\u{1F680} Code Refactoring</h2>\n<h3>v1.11.1</h3>\n<h4>\u{1F4E6} Update</h4>\n<ul>\n<li>Optimized CSS for rending <code>rich foot</code> element at the bottom of notes</li>\n</ul>\n<h3>v1.11.0</h3>\n<p>This release represents a complete architectural overhaul of the Rich Foot plugin, implementing modern best practices and significant performance improvements.</p>\n<h4>\u2728 Performance Enhancements</h4>\n<ul>\n<li>Implemented <code>requestAnimationFrame</code> for all visual updates to eliminate page jitter</li>\n<li>Optimized MutationObserver usage with RAF-debounced callbacks</li>\n<li>Added CSS <code>contain</code> and <code>will-change</code> properties for better rendering performance</li>\n<li>Reduced layout thrashing through batched DOM operations</li>\n<li>Smart update detection to skip unnecessary re-renders</li>\n</ul>\n<h4>\u{1F3D7}\uFE0F Architecture Improvements</h4>\n<ul>\n<li>Complete code reorganization with separation of concerns</li>\n<li>New modular structure:<ul>\n<li><strong>RichFootDataManager</strong>: Handles all data fetching and parsing</li>\n<li><strong>RichFootRenderer</strong>: Pure rendering logic with optimal DOM operations</li>\n<li><strong>RichFootViewManager</strong>: View lifecycle and observer management</li>\n</ul>\n</li>\n<li>Eliminated code duplication across date parsing and link creation</li>\n<li>Cleaner, more maintainable codebase with JSDoc documentation</li>\n</ul>\n<h4>\u{1F9F9} Cleanup &amp; Stability</h4>\n<ul>\n<li>Proper resource cleanup using Obsidian&#39;s <code>registerEvent</code> exclusively</li>\n<li>Improved observer management with automatic disconnection</li>\n<li>Data attributes for better element tracking</li>\n<li>No more manual event cleanup in <code>onunload</code> (automatic via registration)</li>\n<li>Fixed potential memory leaks from orphaned observers</li>\n</ul>\n<h4>\u{1F527} Compatibility</h4>\n<ul>\n<li>Enhanced native hover preview integration (works in all modes)</li>\n<li>Respects view lifecycle changes more accurately</li>\n</ul>\n<h4>\u{1F4CA} Code Quality</h4>\n<ul>\n<li>Comprehensive error handling with try-catch blocks</li>\n<li>Modern ES6+ patterns throughout</li>\n<li>Clear naming conventions and documentation</li>\n</ul>\n<h4>\u{1F3A8} CSS Optimizations</h4>\n<ul>\n<li>Added GPU-accelerated transforms for animations</li>\n<li>Optimized transitions with <code>will-change</code> hints</li>\n<li>Layout containment for better performance</li>\n<li>Smoother fade-in animations</li>\n</ul>\n<p>This update maintains 100% backwards compatibility with all existing settings and configurations while providing a more robust, performant foundation for future enhancements.</p>\n";
// src/settings.js
var import_obsidian2 = require("obsidian");
// src/utils.js
function rgbToHex(color) {
if (color.startsWith("hsl")) {
const temp = document.createElement("div");
temp.style.color = color;
document.body.appendChild(temp);
color = getComputedStyle(temp).color;
document.body.removeChild(temp);
}
const rgb = color.match(/\d+/g);
if (!rgb || rgb.length < 3) return "#000000";
const [r, g, b] = rgb.slice(0, 3).map((x) => {
const val = Math.min(255, Math.max(0, Math.round(parseFloat(x))));
return val.toString(16).padStart(2, "0");
});
return `#${r}${g}${b}`;
}
function resolveCssVar(cssValue, property = "color") {
const temp = document.createElement("div");
temp.style[property] = cssValue;
document.body.appendChild(temp);
const computed = getComputedStyle(temp)[property];
document.body.removeChild(temp);
return rgbToHex(computed);
}
// src/settings.js
var DEFAULT_SETTINGS = {
borderWidth: 1,
borderStyle: "dashed",
borderOpacity: 1,
borderRadius: 15,
datesOpacity: 1,
linksOpacity: 1,
showReleaseNotes: true,
excludedFolders: [],
dateColor: "var(--text-accent)",
borderColor: "var(--text-accent)",
linkColor: "var(--link-color)",
linkBackgroundColor: "var(--tag-background)",
linkBorderColor: "rgba(255, 255, 255, 0.204)",
customCreatedDateProp: "",
customModifiedDateProp: "",
dateDisplayFormat: "mmmm dd, yyyy",
showBacklinks: true,
showOutlinks: true,
showDates: true,
combineLinks: false,
limitLinks: false,
linksLimit: 10,
footerWidth: "default",
footerMaxWidth: 700,
updateDelay: 3e3,
excludedParentSelectors: [],
frontmatterExclusionField: ""
};
var RichFootSettingTab = class extends import_obsidian2.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
this.excludedParentSelectors = [];
this.frontmatterExclusionField = "";
}
display() {
var _a, _b;
const { containerEl } = this;
containerEl.empty();
containerEl.addClass("rich-foot-settings");
containerEl.createEl("div", { cls: "rich-foot-info", text: "\u{1F9B6} Rich Foot adds a footer to your notes with useful information such as backlinks, creation date, and last modified date. Use the settings below to customize the appearance." });
containerEl.createEl("h3", { text: "Visibility Settings" });
new import_obsidian2.Setting(containerEl).setName("Show Backlinks").setDesc("Show backlinks in the footer").addToggle((toggle) => toggle.setValue(this.plugin.settings.showBacklinks).onChange(async (value) => {
this.plugin.settings.showBacklinks = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}));
new import_obsidian2.Setting(containerEl).setName("Show Outlinks").setDesc("Show outgoing links in the footer").addToggle((toggle) => toggle.setValue(this.plugin.settings.showOutlinks).onChange(async (value) => {
this.plugin.settings.showOutlinks = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}));
new import_obsidian2.Setting(containerEl).setName("Show Combine Links").setDesc("Show backlinks and outlinks in a single combined section (overrides show backlinks and show outlinks settings)").addToggle((toggle) => toggle.setValue(this.plugin.settings.combineLinks).onChange(async (value) => {
this.plugin.settings.combineLinks = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}));
new import_obsidian2.Setting(containerEl).setName("Limit Links Shown").setDesc('Limit the number of backlinks/outlinks shown in the footer. Surplus links are hidden behind a "Show More" button that expands them on click.').addToggle((toggle) => toggle.setValue(this.plugin.settings.limitLinks).onChange(async (value) => {
this.plugin.settings.limitLinks = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}));
let linksLimitInput;
new import_obsidian2.Setting(containerEl).setName("Links Limit").setDesc('Maximum number of links to show before the "Show More" button (only applies when "Limit Links Shown" is enabled)').addText((text) => {
linksLimitInput = text;
text.setPlaceholder("10").setValue(String(this.plugin.settings.linksLimit)).onChange(async (value) => {
const numValue = Math.floor(Number(value));
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.linksLimit = numValue;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.linksLimit = DEFAULT_SETTINGS.linksLimit;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
if (linksLimitInput) {
linksLimitInput.setValue(String(DEFAULT_SETTINGS.linksLimit));
}
}));
let updateDelayInput;
new import_obsidian2.Setting(containerEl).setName("Rich-foot update delay").setDesc("Delay in milliseconds before updating the rich-foot in edit mode (lower values may impact performance)").addText((text) => {
updateDelayInput = text;
text.setPlaceholder("3000").setValue(String(this.plugin.settings.updateDelay)).onChange(async (value) => {
const numValue = Math.floor(Number(value));
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.updateDelay = numValue;
await this.plugin.saveSettings();
}
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.updateDelay = DEFAULT_SETTINGS.updateDelay;
await this.plugin.saveSettings();
if (updateDelayInput) {
updateDelayInput.setValue(String(DEFAULT_SETTINGS.updateDelay));
}
}));
containerEl.createEl("hr");
containerEl.createEl("h3", { text: "Date Settings" });
new import_obsidian2.Setting(containerEl).setName("Show Dates").setDesc("Show creation and modification dates in the footer").addToggle((toggle) => toggle.setValue(this.plugin.settings.showDates).onChange(async (value) => {
this.plugin.settings.showDates = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}));
new import_obsidian2.Setting(containerEl).setName("Date Display Format").setDesc("Choose how dates should be displayed in the footer").addDropdown((dropdown) => {
const today = /* @__PURE__ */ new Date();
const formats = [
"mm/dd/yyyy",
"dd/mm/yyyy",
"yyyy-mm-dd",
"mmm dd, yyyy",
"dd mmm yyyy",
"mmmm dd, yyyy",
"ddd, mmm dd, yyyy",
"dddd, mmmm dd, yyyy",
"mm/dd/yy",
"dd/mm/yy",
"yy-mm-dd",
"m/d/yy"
];
formats.forEach((format) => {
const example = this.plugin.dataManager.formatDate(today, format);
dropdown.addOption(format, `${format} (${example})`);
});
dropdown.setValue(this.plugin.settings.dateDisplayFormat).onChange(async (value) => {
this.plugin.settings.dateDisplayFormat = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
});
new import_obsidian2.Setting(containerEl).setName("Custom Created Date Property").setDesc("Specify a frontmatter property to use for creation date (leave empty to use file creation date)").addText((text) => {
text.setValue(this.plugin.settings.customCreatedDateProp).onChange(async (value) => {
this.plugin.settings.customCreatedDateProp = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
this.createdDateInput = text;
return text;
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.customCreatedDateProp = "";
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
this.createdDateInput.setValue("");
}));
new import_obsidian2.Setting(containerEl).setName("Custom Modified Date Property").setDesc("Specify a frontmatter property to use for modification date (leave empty to use file modification date)").addText((text) => {
text.setValue(this.plugin.settings.customModifiedDateProp).onChange(async (value) => {
this.plugin.settings.customModifiedDateProp = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
this.modifiedDateInput = text;
return text;
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.customModifiedDateProp = "";
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
this.modifiedDateInput.setValue("");
}));
containerEl.createEl("hr");
containerEl.createEl("h3", { text: "Style Settings" });
let customWidthSetting;
let footerMaxWidthSlider;
const toggleCustomWidthVisibility = (value) => {
if (customWidthSetting) {
customWidthSetting.settingEl.style.display = value === "custom" ? "" : "none";
}
};
new import_obsidian2.Setting(containerEl).setName("Footer Width").setDesc(`Control how wide the footer is. "Readable line length" locks the footer to Obsidian's readable line width (recommended when your notes use Readable Line Length), and "Custom" lets you set a maximum width in pixels. The footer never grows wider than the note, so it won't cause horizontal scrolling on narrow screens.`).addDropdown((dropdown) => {
dropdown.addOptions({
"default": "Default (match content width)",
"readable": "Readable line length",
"custom": "Custom width (px)"
}).setValue(this.plugin.settings.footerWidth).onChange(async (value) => {
this.plugin.settings.footerWidth = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
toggleCustomWidthVisibility(value);
});
});
let footerMaxWidthDebounce;
customWidthSetting = new import_obsidian2.Setting(containerEl).setName("Custom Footer Width").setDesc("Maximum width of the footer in pixels (200-1200px)").addSlider((slider) => {
footerMaxWidthSlider = slider;
slider.setLimits(200, 1200, 10).setValue(this.plugin.settings.footerMaxWidth).setDynamicTooltip().onChange((value) => {
this.plugin.settings.footerMaxWidth = value;
document.documentElement.style.setProperty("--rich-foot-content-max-width", value + "px");
if (footerMaxWidthDebounce) clearTimeout(footerMaxWidthDebounce);
footerMaxWidthDebounce = setTimeout(async () => {
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
}, 300);
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.footerMaxWidth = DEFAULT_SETTINGS.footerMaxWidth;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
footerMaxWidthSlider.setValue(DEFAULT_SETTINGS.footerMaxWidth);
}));
toggleCustomWidthVisibility(this.plugin.settings.footerWidth);
let borderWidthSlider;
new import_obsidian2.Setting(containerEl).setName("Border Width").setDesc("Adjust the width of the footer border (1-10px)").addSlider((slider) => {
borderWidthSlider = slider;
slider.setLimits(1, 10, 1).setValue(this.plugin.settings.borderWidth).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.borderWidth = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.borderWidth = DEFAULT_SETTINGS.borderWidth;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
borderWidthSlider.setValue(DEFAULT_SETTINGS.borderWidth);
}));
let borderStyleDropdown;
new import_obsidian2.Setting(containerEl).setName("Border Style").setDesc("Choose the style of the footer border").addDropdown((dropdown) => {
borderStyleDropdown = dropdown;
dropdown.addOptions({
"solid": "Solid",
"dashed": "Dashed",
"dotted": "Dotted",
"double": "Double",
"groove": "Groove",
"ridge": "Ridge",
"inset": "Inset",
"outset": "Outset"
}).setValue(this.plugin.settings.borderStyle).onChange(async (value) => {
this.plugin.settings.borderStyle = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.borderStyle = DEFAULT_SETTINGS.borderStyle;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
borderStyleDropdown.setValue(DEFAULT_SETTINGS.borderStyle);
}));
let borderOpacitySlider;
new import_obsidian2.Setting(containerEl).setName("Border Opacity").setDesc("Adjust the opacity of the footer border (0-1)").addSlider((slider) => {
borderOpacitySlider = slider;
slider.setLimits(0, 1, 0.1).setValue(this.plugin.settings.borderOpacity).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.borderOpacity = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.borderOpacity = DEFAULT_SETTINGS.borderOpacity;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
borderOpacitySlider.setValue(DEFAULT_SETTINGS.borderOpacity);
}));
let borderColorPicker;
new import_obsidian2.Setting(containerEl).setName("Border Color").setDesc("Choose the color for the footer border").addColorPicker((color) => {
borderColorPicker = color;
color.setValue(this.plugin.settings.borderColor.startsWith("var(--") ? resolveCssVar("var(--text-accent)", "borderColor") : this.plugin.settings.borderColor).onChange(async (value) => {
this.plugin.settings.borderColor = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.borderColor = DEFAULT_SETTINGS.borderColor;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
borderColorPicker.setValue(resolveCssVar("var(--text-accent)", "borderColor"));
}));
let borderRadiusSlider;
new import_obsidian2.Setting(containerEl).setName("Link Border Radius").setDesc("Adjust the border radius of Backlinks and Outlinks (0-15px)").addSlider((slider) => {
borderRadiusSlider = slider;
slider.setLimits(0, 15, 1).setValue(this.plugin.settings.borderRadius).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.borderRadius = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.borderRadius = DEFAULT_SETTINGS.borderRadius;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
borderRadiusSlider.setValue(DEFAULT_SETTINGS.borderRadius);
}));
let linksOpacitySlider;
new import_obsidian2.Setting(containerEl).setName("Links Opacity").setDesc("Adjust the opacity of Backlinks and Outlinks (0-1)").addSlider((slider) => {
linksOpacitySlider = slider;
slider.setLimits(0, 1, 0.1).setValue(this.plugin.settings.linksOpacity).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.linksOpacity = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.linksOpacity = DEFAULT_SETTINGS.linksOpacity;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
linksOpacitySlider.setValue(DEFAULT_SETTINGS.linksOpacity);
}));
let linkColorPicker;
new import_obsidian2.Setting(containerEl).setName("Link Text Color").setDesc("Choose the color for link text").addColorPicker((color) => {
linkColorPicker = color;
color.setValue(this.plugin.settings.linkColor.startsWith("var(--") ? resolveCssVar("var(--link-color)", "color") : this.plugin.settings.linkColor).onChange(async (value) => {
this.plugin.settings.linkColor = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.linkColor = DEFAULT_SETTINGS.linkColor;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
linkColorPicker.setValue(resolveCssVar("var(--link-color)", "color"));
}));
let linkBackgroundColorPicker;
new import_obsidian2.Setting(containerEl).setName("Link Background Color").setDesc("Choose the background color for links").addColorPicker((color) => {
linkBackgroundColorPicker = color;
color.setValue(this.plugin.settings.linkBackgroundColor.startsWith("var(--") ? resolveCssVar("var(--tag-background)", "backgroundColor") : this.plugin.settings.linkBackgroundColor).onChange(async (value) => {
this.plugin.settings.linkBackgroundColor = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.linkBackgroundColor = DEFAULT_SETTINGS.linkBackgroundColor;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
linkBackgroundColorPicker.setValue(resolveCssVar("var(--tag-background)", "backgroundColor"));
}));
let linkBorderColorPicker;
new import_obsidian2.Setting(containerEl).setName("Link Border Color").setDesc("Choose the border color for links").addColorPicker((color) => {
linkBorderColorPicker = color;
color.setValue(this.plugin.settings.linkBorderColor.startsWith("rgba(") ? resolveCssVar(this.plugin.settings.linkBorderColor, "borderColor") : this.plugin.settings.linkBorderColor).onChange(async (value) => {
this.plugin.settings.linkBorderColor = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.linkBorderColor = DEFAULT_SETTINGS.linkBorderColor;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
linkBorderColorPicker.setValue(resolveCssVar(DEFAULT_SETTINGS.linkBorderColor, "borderColor"));
}));
let datesOpacitySlider;
new import_obsidian2.Setting(containerEl).setName("Dates Opacity").setDesc("Adjust the opacity of the Created / Modified Dates (0-1)").addSlider((slider) => {
datesOpacitySlider = slider;
slider.setLimits(0, 1, 0.1).setValue(this.plugin.settings.datesOpacity).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.datesOpacity = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.datesOpacity = DEFAULT_SETTINGS.datesOpacity;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
datesOpacitySlider.setValue(DEFAULT_SETTINGS.datesOpacity);
}));
let dateColorPicker;
new import_obsidian2.Setting(containerEl).setName("Date Color").setDesc("Choose the color for Created / Modified Dates").addColorPicker((color) => {
dateColorPicker = color;
color.setValue(this.plugin.settings.dateColor.startsWith("var(--") ? resolveCssVar("var(--text-accent)", "color") : this.plugin.settings.dateColor).onChange(async (value) => {
this.plugin.settings.dateColor = value;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.dateColor = DEFAULT_SETTINGS.dateColor;
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
dateColorPicker.setValue(resolveCssVar("var(--text-accent)", "color"));
}));
containerEl.createEl("hr");
containerEl.createEl("h3", { text: "Exclusion Rules" });
containerEl.createEl("h3", { text: "Excluded Folders" });
containerEl.createEl("p", {
text: "Notes in excluded folders (and their subfolders) will not display the Rich Foot footer. This is useful for system folders or areas where you don't want footer information to appear.",
cls: "setting-item-description"
});
const excludedFoldersContainer = containerEl.createDiv("excluded-folders-container");
if ((_a = this.plugin.settings) == null ? void 0 : _a.excludedFolders) {
this.plugin.settings.excludedFolders.forEach((folder, index) => {
const folderDiv = excludedFoldersContainer.createDiv("excluded-folder-item");
folderDiv.createSpan({ text: folder });
const deleteButton = folderDiv.createEl("button", {
text: "Delete",
cls: "excluded-folder-delete"
});
deleteButton.addEventListener("click", async () => {
this.plugin.settings.excludedFolders.splice(index, 1);
await this.plugin.saveSettings();
this.display();
});
});
}
const newFolderSetting = new import_obsidian2.Setting(containerEl).setName("Add excluded folder").setDesc("Enter a folder path or browse to select").addText((text) => text.setPlaceholder("folder/subfolder")).addButton((button) => button.setButtonText("Browse").onClick(async () => {
const folder = await this.browseForFolder();
if (folder) {
const textComponent = newFolderSetting.components[0];
textComponent.setValue(folder);
}
})).addButton((button) => button.setButtonText("Add").onClick(async () => {
const textComponent = newFolderSetting.components[0];
const newFolder = textComponent.getValue().trim();
if (newFolder && !this.plugin.settings.excludedFolders.includes(newFolder)) {
this.plugin.settings.excludedFolders.push(newFolder);
await this.plugin.saveSettings();
textComponent.setValue("");
this.display();
}
}));
containerEl.createEl("h4", { text: "Exclude Rich Foot via Frontmatter" });
let frontmatterExclusionInput;
new import_obsidian2.Setting(containerEl).setName("Frontmatter Exclusion Field").setDesc("If this frontmatter field exists and has a truthy value (true, yes, 1, on), Rich Foot will not be shown on that note").addText((text) => {
frontmatterExclusionInput = text;
text.setPlaceholder("e.g., exclude-rich-foot").setValue(this.plugin.settings.frontmatterExclusionField).onChange(async (value) => {
this.plugin.settings.frontmatterExclusionField = value.trim();
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
});
}).addButton((button) => button.setButtonText("Reset").onClick(async () => {
this.plugin.settings.frontmatterExclusionField = "";
await this.plugin.saveSettings();
await this.plugin.updateRichFoot();
if (frontmatterExclusionInput) {
frontmatterExclusionInput.setValue("");
}
}));
containerEl.createEl("h4", { text: "Excluded Parent Selectors" });
containerEl.createEl("p", {
text: "Rich Foot will not be added to notes that have any of these parent selectors in their DOM hierarchy. Useful for compatibility with other plugins.",
cls: "setting-item-description"
});
const excludedSelectorsContainer = containerEl.createDiv("excluded-selectors-container");
if ((_b = this.plugin.settings) == null ? void 0 : _b.excludedParentSelectors) {
this.plugin.settings.excludedParentSelectors.forEach((selector, index) => {
const selectorDiv = excludedSelectorsContainer.createDiv("excluded-selector-item");
selectorDiv.createSpan({ text: selector });
const deleteButton = selectorDiv.createEl("button", {
text: "Delete",
cls: "excluded-selector-delete"
});
deleteButton.addEventListener("click", async () => {
this.plugin.settings.excludedParentSelectors.splice(index, 1);
await this.plugin.saveSettings();
this.display();
});
});
}
const newSelectorSetting = new import_obsidian2.Setting(containerEl).setName("Add excluded parent selector").setDesc('Enter a CSS selector (e.g., .some-class, #some-id, [data-type="special"])').addText((text) => text.setPlaceholder("Enter selector").onChange(() => {
try {
document.querySelector(text.getValue());
text.inputEl.removeClass("rich-foot-input-error");
} catch (e) {
text.inputEl.addClass("rich-foot-input-error");
}
})).addButton((button) => button.setButtonText("Add").onClick(async () => {
const textComponent = newSelectorSetting.components[0];
const newSelector = textComponent.getValue().trim();
if (!newSelector) return;
try {
document.querySelector(newSelector);
} catch (e) {
new import_obsidian2.Notice("Invalid CSS selector");
return;
}
if (!this.plugin.settings.excludedParentSelectors.includes(newSelector)) {
this.plugin.settings.excludedParentSelectors.push(newSelector);
await this.plugin.saveSettings();
textComponent.setValue("");
this.display();
}
}));
containerEl.createEl("h3", { text: "Example Screenshot", cls: "rich-foot-example-title" });
const exampleDiv = containerEl.createDiv({ cls: "rich-foot-example" });
exampleDiv.createEl("img", {
attr: {
src: "https://raw.githubusercontent.com/jparkerweb/rich-foot/refs/heads/main/rich-foot.jpg",
alt: "Rich Foot Example"
}
});
new import_obsidian2.Setting(containerEl).setName("Show Release Notes").setDesc("Show release notes after plugin updates").addToggle((toggle) => toggle.setValue(this.plugin.settings.showReleaseNotes).onChange(async (value) => {
this.plugin.settings.showReleaseNotes = value;
await this.plugin.saveSettings();
}));
new import_obsidian2.Setting(containerEl).setName("View Release Notes").setDesc("View release notes for the current version").addButton((button) => button.setButtonText("View Release Notes").onClick(async () => {
const notes = await this.plugin.getReleaseNotes(this.plugin.manifest.version);
new ReleaseNotesModal(this.app, this.plugin, this.plugin.manifest.version, notes).open();
}));
}
async browseForFolder() {
const folders = this.app.vault.getAllLoadedFiles().filter((file) => file.children).map((folder) => folder.path);
return new Promise((resolve) => {
const modal = new FolderSuggestModal(this.app, folders, (result) => {
resolve(result);
});
modal.open();
});
}
};
var FolderSuggestModal = class extends import_obsidian2.FuzzySuggestModal {
constructor(app, folders, onChoose) {
super(app);
this.folders = folders;
this.onChoose = onChoose;
}
getItems() {
return this.folders;
}
getItemText(item) {
return item;
}
onChooseItem(item, evt) {
this.onChoose(item);
}
};
// src/data-manager.js
var RichFootDataManager = class {
constructor(app) {
this.app = app;
}
/**
* Get backlinks for a file
* @param {TFile} file - The file to get backlinks for
* @returns {Map} Map of backlink paths to their data
*/
getBacklinks(file) {
const resolvedLinks = this.app.metadataCache.resolvedLinks;
const backlinks = /* @__PURE__ */ new Map();
for (const sourcePath in resolvedLinks) {
const targets = resolvedLinks[sourcePath];
if (targets[file.path]) {
backlinks.set(sourcePath, targets[file.path]);
}
}
return backlinks;
}
/**
* Get all outlinks from a file (links, embeds, frontmatter, footnotes)
* @param {TFile} file - The file to get outlinks from
* @returns {Promise<Set>} Set of outlink paths
*/
async getOutlinks(file) {
const cache = this.app.metadataCache.getFileCache(file);
const links = /* @__PURE__ */ new Set();
if (cache == null ? void 0 : cache.links) {
for (const link of cache.links) {
this.addResolvedLink(link.link, file, links);
}
}
if (cache == null ? void 0 : cache.embeds) {
for (const embed of cache.embeds) {
this.addResolvedLink(embed.link, file, links);
}
}
if (cache == null ? void 0 : cache.frontmatterLinks) {
for (const link of cache.frontmatterLinks) {
this.addResolvedLink(link.link, file, links);
}
}
if (cache == null ? void 0 : cache.blocks) {
for (const block of Object.values(cache.blocks)) {
if (block.type === "footnote") {
this.extractWikiLinks(block.text, file, links);
}
}
}
const fileContent = await this.app.vault.cachedRead(file);
this.processFootnotes(fileContent, file, links);
return links;
}
/**
* Add a resolved link to the set
* @private
*/
addResolvedLink(linkText, sourceFile, linksSet) {
const linkPath = linkText.split("#")[0];
const targetFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, sourceFile.path);
if (targetFile && targetFile.extension === "md") {
linksSet.add(targetFile.path);
}
}
/**
* Extract wiki links from text
* @private
*/
extractWikiLinks(text, sourceFile, linksSet) {
const wikiLinkRegex = /\[\[(.*?)\]\]/g;
let match;
while ((match = wikiLinkRegex.exec(text)) !== null) {
const linkText = match[1].trim();
this.addResolvedLink(linkText, sourceFile, linksSet);
}
}
/**
* Process footnotes from file content
* @private
*/
processFootnotes(content, file, links) {
const inlineFootnoteRegex = /\^\[((?:[^\[\]]|\[(?:[^\[\]]|\[[^\[\]]*\])*\])*)\]/g;
const refFootnoteRegex = /\[\^[^\]]+\]:\s*((?:[^\[\]]|\[(?:[^\[\]]|\[[^\[\]]*\])*\])*)/g;
let match;
while ((match = inlineFootnoteRegex.exec(content)) !== null) {
const footnoteContent = match[1];
this.extractWikiLinks(footnoteContent, file, links);
}
while ((match = refFootnoteRegex.exec(content)) !== null) {
const footnoteContent = match[1];
this.extractWikiLinks(footnoteContent, file, links);
}
}
/**
* Get dates for a file (created and modified)
* @param {TFile} file - The file to get dates for
* @param {Object} settings - Plugin settings
* @returns {Object} { created, modified } formatted dates
*/
getDates(file, settings) {
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = cache == null ? void 0 : cache.frontmatter;
return {
created: this.getFormattedDate(
file,
frontmatter,
settings.customCreatedDateProp,
"ctime",
settings.dateDisplayFormat
),
modified: this.getFormattedDate(
file,
frontmatter,
settings.customModifiedDateProp,
"mtime",
settings.dateDisplayFormat
)
};
}
/**
* Get a formatted date from frontmatter or file stats
* @private
*/
getFormattedDate(file, frontmatter, customProp, statProp, format) {
let dateValue;
if (customProp && frontmatter && frontmatter[customProp]) {
const parsed = this.parseDate(frontmatter[customProp]);
if (parsed) {
dateValue = parsed;
}
}
if (!dateValue) {
dateValue = new Date(file.stat[statProp]);
}
return this.formatDate(dateValue, format);
}
/**
* Parse a date string with multiple format attempts
* @param {string} value - Date string to parse
* @returns {Date|null} Parsed date or null if invalid
*/
parseDate(value) {
if (!value) return null;
let tempDate = String(value);
if (!isNaN(Date.parse(tempDate))) {
return this.createDateWithTime(tempDate);
}
tempDate = this.replaceNTimes(String(value), /\./g, "-", 2);
if (!isNaN(Date.parse(tempDate))) {
return this.createDateWithTime(tempDate);
}
tempDate = this.replaceNTimes(String(value), /\//g, "-", 2);
if (!isNaN(Date.parse(tempDate))) {
return this.createDateWithTime(tempDate);
}
return null;
}
/**
* Replace pattern N times in a string
* @private
*/
replaceNTimes(str, pattern, replacement, times) {
let count = 0;
return str.replace(pattern, (match) => {
count++;
return count <= times ? replacement : match;
});
}
/**
* Create date object and add midnight time if no time component exists
* @private
*/
createDateWithTime(dateStr) {
let tempDate = dateStr;
if (!tempDate.includes("T") && !tempDate.includes(" ")) {
tempDate = `${tempDate}T00:00:00`;
}
return new Date(tempDate);
}
/**
* Format a date according to the specified format
* @param {Date} date - Date to format
* @param {string} format - Format string
* @returns {string} Formatted date string
*/
formatDate(date, format) {
const d = new Date(date);
const year = d.getFullYear();
const month = d.getMonth();
const day = d.getDate();
const weekday = d.getDay();
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
const monthsShort = months.map((m) => m.slice(0, 3));
const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const weekdaysShort = weekdays.map((w) => w.slice(0, 3));
const pad = (num) => num.toString().padStart(2, "0");
const tokens = {
"dddd": weekdays[weekday],
"ddd": weekdaysShort[weekday],
"dd": pad(day),
"d": day.toString(),
"mmmm": months[month],
"mmm": monthsShort[month],
"mm": pad(month + 1),
"m": (month + 1).toString(),
"yyyy": year.toString(),
"yy": year.toString().slice(-2)
};
const sortedTokens = Object.keys(tokens).sort((a, b) => b.length - a.length);
let result = format.toLowerCase();
const replacements = /* @__PURE__ */ new Map();
sortedTokens.forEach((token, index) => {
const placeholder = `__${index}__`;
replacements.set(placeholder, tokens[token]);
result = result.replace(new RegExp(token, "gi"), placeholder);
});
replacements.forEach((value, placeholder) => {
result = result.replace(new RegExp(placeholder, "g"), value);
});
return result;
}
};
// src/renderer.js
var import_obsidian3 = require("obsidian");
var showMoreIdCounter = 0;
var RichFootRenderer = class {
constructor(plugin) {
this.plugin = plugin;
}
/**
* Create the complete footer element for a file
* @param {TFile} file - The file to create footer for
* @param {Object} data - Pre-fetched data { backlinks, outlinks, dates }
* @returns {HTMLElement} The footer element
*/
createFooter(file, data) {
const { backlinks, outlinks, dates } = data;
const { settings } = this.plugin;
const richFoot = createDiv({ cls: "rich-foot rich-foot--hidden" });
richFoot.setAttribute("data-rich-foot", "true");
richFoot.setAttribute("data-file-path", file.path);
richFoot.createDiv({ cls: "rich-foot--dashed-line" });
if (settings.combineLinks) {
this.createCombinedLinksSection(richFoot, file, backlinks, outlinks);
} else {
if (settings.showBacklinks) {
this.createLinksSection(richFoot, file, backlinks, "backlinks");
}
if (settings.showOutlinks) {
this.createLinksSection(richFoot, file, outlinks, "outlinks");
}
}
if (settings.showDates && dates) {
this.createDatesSection(richFoot, dates);
}
return richFoot;
}
/**
* Create combined links section (backlinks + outlinks)
* @private
*/
createCombinedLinksSection(container, file, backlinks, outlinks) {
if (backlinks.size === 0 && outlinks.size === 0) return;
const linksDiv = container.createDiv({ cls: "rich-foot--links" });
const linksUl = linksDiv.createEl("ul");
const processedLinks = /* @__PURE__ */ new Set();
for (const [linkPath] of backlinks) {
if (!linkPath.endsWith(".md")) continue;
processedLinks.add(linkPath);
const metadata = {
isBacklink: true,
isOutlink: outlinks.has(linkPath)
};
const li = linksUl.createEl("li");
this.createLinkElement(li, file, linkPath, metadata);
}
for (const linkPath of outlinks) {
if (processedLinks.has(linkPath)) continue;
const metadata = {
isBacklink: false,
isOutlink: true
};
const li = linksUl.createEl("li");
this.createLinkElement(li, file, linkPath, metadata);
}
if (linksUl.childElementCount === 0) {
linksDiv.remove();
return;
}
this.applyLinkLimit(linksUl);
}
/**
* Create links section (backlinks or outlinks)
* @private
*/
createLinksSection(container, file, links, type) {
const linksArray = type === "backlinks" ? Array.from(links.keys()).filter((path) => path.endsWith(".md")) : Array.from(links);
if (linksArray.length === 0) return;
const className = `rich-foot--${type}`;
const linksDiv = container.createDiv({ cls: className });
const linksUl = linksDiv.createEl("ul");
for (const linkPath of linksArray) {
const li = linksUl.createEl("li");
const metadata = {
isBacklink: type === "backlinks",
isOutlink: type === "outlinks"
};
this.createLinkElement(li, file, linkPath, metadata);
}
if (linksUl.childElementCount === 0) {
linksDiv.remove();
return;
}
this.applyLinkLimit(linksUl);
}
/**
* Limit the number of visible links, hiding the surplus behind a
* toggleable "Show More (X)" button.
* @private
* @param {HTMLElement} linksUl - The <ul> containing link <li> elements
*/
applyLinkLimit(linksUl) {
const { settings } = this.plugin;
if (!settings.limitLinks) return;
const limit = Math.floor(Number(settings.linksLimit));
if (!Number.isFinite(limit) || limit < 1) return;
const items = Array.from(linksUl.children);
if (items.length <= limit) return;
const surplus = items.length - limit;
const listId = `rich-foot-links-${++showMoreIdCounter}`;
linksUl.id = listId;
for (let i = limit; i < items.length; i++) {
items[i].addClass("rich-foot--link-hidden");
}
const toggleLi = linksUl.createEl("li", { cls: "rich-foot--show-more-li" });
const toggleBtn = toggleLi.createEl("button", {
cls: "rich-foot--show-more",
text: `Show More (${surplus})`,
attr: {
type: "button",
"aria-expanded": "false",
"aria-controls": listId
}
});
let expanded = false;
toggleBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
expanded = !expanded;
for (let i = limit; i < items.length; i++) {
items[i].toggleClass("rich-foot--link-hidden", !expanded);
}
toggleBtn.setText(expanded ? "Show Less" : `Show More (${surplus})`);
toggleBtn.setAttribute("aria-expanded", String(expanded));
});
}
/**
* Create a single link element
* @private
*/
createLinkElement(container, file, linkPath, metadata) {
const displayName = linkPath.split("/").pop().slice(0, -3);
const isEditMode = this.isEditMode();
const link = container.createEl("a", {
href: linkPath,
text: displayName,
cls: isEditMode ? "cm-hmd-internal-link cm-underline" : "internal-link"
});
link.dataset.href = linkPath;
link.dataset.sourcePath = file.path;
if (metadata.isBacklink) link.dataset.isBacklink = "true";
if (metadata.isOutlink) link.dataset.isOutlink = "true";
this.setupLinkHandlers(link, linkPath, file);
return link;
}
/**
* Setup event handlers for a link element
* @private
*/
setupLinkHandlers(link, linkPath, file) {
link.addEventListener("click", (event) => {
event.preventDefault();
this.plugin.app.workspace.openLinkText(linkPath, file.path);
});
if (this.isEditMode()) {
this.setupHoverPreview(link, linkPath, file);
}
}
/**
* Setup hover preview for a link
* @private
*/
setupHoverPreview(link, linkPath, file) {
const pagePreviewPlugin = this.plugin.app.internalPlugins.plugins["page-preview"];
if (!(pagePreviewPlugin == null ? void 0 : pagePreviewPlugin.enabled)) return;
link.addEventListener("mouseover", (mouseEvent) => {
const previewPlugin = pagePreviewPlugin.instance;
if (previewPlugin == null ? void 0 : previewPlugin.onLinkHover) {
previewPlugin.onLinkHover(mouseEvent, link, linkPath, file.path);
}
});
}
/**
* Create dates section
* @private
*/
createDatesSection(container, dates) {
const datesWrapper = container.createDiv({ cls: "rich-foot--dates-wrapper" });
datesWrapper.createDiv({
cls: "rich-foot--modified-date",
text: dates.modified
});
datesWrapper.createDiv({
cls: "rich-foot--created-date",
text: dates.created
});
}
/**
* Attach footer to container with fade-in animation using RAF
* @param {HTMLElement} container - Target container
* @param {HTMLElement} footer - Footer element
*/
attachToContainer(container, footer) {
container.appendChild(footer);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
footer.removeClass("rich-foot--hidden");
});
});
}
/**
* Check if current view is in edit mode
* @private
*/
isEditMode() {
var _a, _b;
const activeView = this.plugin.app.workspace.getActiveViewOfType(import_obsidian3.MarkdownView);
if (!activeView) return false;
const mode = (_b = (_a = activeView.getMode) == null ? void 0 : _a.call(activeView)) != null ? _b : activeView.mode;
return mode === "source";
}
};
// src/view-manager.js
var RichFootViewManager = class {
constructor(plugin) {
this.plugin = plugin;
this.observers = /* @__PURE__ */ new Map();
this.pendingUpdates = /* @__PURE__ */ new Map();
}
/**
* Attach footer to a view
* @param {MarkdownView} view - The view to attach to
*/
async attachToView(view) {
if (!view || !view.file) return;
const file = view.file;
if (this.shouldExclude(view, file)) {
this.detachFromView(view);
return;
}
const container = this.getTargetContainer(view);
if (!container) {
return;
}
if (!this.shouldUpdate(container, file)) {
return;
}
this.cancelPendingUpdate(container);
const data = await this.fetchData(file);
await this.renderFooter(view, container, file, data);
}
/**
* Fetch all data needed for rendering
* @private
*/
async fetchData(file) {
const { dataManager, settings } = this.plugin;
const [backlinks, outlinks, dates] = await Promise.all([
Promise.resolve(dataManager.getBacklinks(file)),
dataManager.getOutlinks(file),
Promise.resolve(dataManager.getDates(file, settings))
]);
return { backlinks, outlinks, dates };
}
/**
* Render footer in container
* @private
*/
async renderFooter(view, container, file, data) {
const rafId = requestAnimationFrame(() => {
try {
this.removeFooterFromContainer(container);
const footer = this.plugin.renderer.createFooter(file, data);
this.plugin.renderer.attachToContainer(container, footer);
this.setupObserver(container, view);
} catch (error) {
console.error("Rich Foot render error:", error);
}
this.pendingUpdates.delete(container);
});
this.pendingUpdates.set(container, rafId);
}
/**
* Check if should update footer
* @private
*/
shouldUpdate(container, file) {
const existingFooter = container.querySelector(".rich-foot[data-rich-foot]");
if (!existingFooter) return true;
const currentPath = existingFooter.getAttribute("data-file-path");
return currentPath !== file.path;
}
/**
* Cancel pending RAF update
* @private
*/
cancelPendingUpdate(container) {
const rafId = this.pendingUpdates.get(container);
if (rafId) {
cancelAnimationFrame(rafId);
this.pendingUpdates.delete(container);
}
}
/**
* Detach footer from a view
* @param {MarkdownView} view - The view to detach from
*/
detachFromView(view) {
if (!view || !view.contentEl) return;
const footers = view.contentEl.querySelectorAll(".rich-foot[data-rich-foot]");
footers.forEach((footer) => footer.remove());
const containers = this.getContainersInView(view);
containers.forEach((container) => {
this.disconnectObserver(container);
});
}
/**
* Get all containers in a view that might have footers
* @private
*/
getContainersInView(view) {
const containers = [];
const previewSection = view.contentEl.querySelector(".markdown-preview-section");
if (previewSection) containers.push(previewSection);
const cmSizer = view.contentEl.querySelector(".cm-sizer");
if (cmSizer) containers.push(cmSizer);
return containers;
}
/**
* Get the target container for footer attachment
* @param {MarkdownView} view - The view
* @returns {HTMLElement|null} Container element or null
*/
getTargetContainer(view) {
var _a, _b;
const mode = (_b = (_a = view.getMode) == null ? void 0 : _a.call(view)) != null ? _b : view.mode;
if (mode === "preview") {
const previewSections = view.contentEl.querySelectorAll(".markdown-preview-section");
for (const section of previewSections) {
if (!section.closest(".internal-embed")) {
return section;
}
}
} else if (mode === "source" || mode === "live") {
return view.contentEl.querySelector(".cm-sizer");
}
return null;
}
/**
* Check if file/view should be excluded
* @param {MarkdownView} view - The view
* @param {TFile} file - The file
* @returns {boolean} True if should exclude
*/
shouldExclude(view, file) {
var _a, _b, _c;
const { settings } = this.plugin;
if ((_a = settings.excludedFolders) == null ? void 0 : _a.some((folder) => file.path.startsWith(folder))) {
return true;
}
if (settings.frontmatterExclusionField) {
const cache = this.plugin.app.metadataCache.getFileCache(file);
const frontmatterValue = (_b = cache == null ? void 0 : cache.frontmatter) == null ? void 0 : _b[settings.frontmatterExclusionField];
if (this.isTruthy(frontmatterValue)) {
return true;
}
}
if (((_c = settings.excludedParentSelectors) == null ? void 0 : _c.length) > 0) {
if (this.hasExcludedParent(view)) {
return true;
}
}
return false;
}
/**
* Check if view has an excluded parent element
* @private
*/
hasExcludedParent(view) {
const { settings } = this.plugin;
return settings.excludedParentSelectors.some((selector) => {
var _a, _b;
try {
let element = view.containerEl;
while (element) {
if ((_a = element.matches) == null ? void 0 : _a.call(element, selector)) {
return true;
}
if ((_b = element.querySelector) == null ? void 0 : _b.call(element, selector)) {
return true;
}
element = element.parentElement;
}
return false;
} catch (e) {
console.error(`Invalid selector in Rich Foot settings: ${selector}`);
return false;
}
});
}
/**
* Check if value is truthy
* @private
*/
isTruthy(value) {
if (!value) return false;
const truthyValues = ["true", "yes", "1", "on"];
return truthyValues.includes(String(value).toLowerCase());
}
/**
* Remove footer from a container
* @private
*/
removeFooterFromContainer(container) {
const footer = container.querySelector(".rich-foot[data-rich-foot]");
if (footer) {
footer.remove();
}
}
/**
* Setup optimized MutationObserver for a container
* @private
*/
setupObserver(container, view) {
this.disconnectObserver(container);
let timeoutId = null;
let rafId = null;
let recheckTimeoutId = null;
const observerCallback = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
timeoutId = setTimeout(() => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(async () => {
rafId = null;
timeoutId = null;
if (!container.isConnected) {
return;
}
const footer = container.querySelector(".rich-foot[data-rich-foot]");
if (!footer) {
recheckTimeoutId = setTimeout(() => {
recheckTimeoutId = null;
const footerRecheck = container.querySelector(".rich-foot[data-rich-foot]");
if (!footerRecheck && container.isConnected) {
try {
this.attachToView(view);
} catch (error) {
console.error("Rich Foot observer re-attach error:", error);
}
}
}, 100);
}
});
}, 150);
};
const observer = new MutationObserver((mutations) => {
const footerRemoved = mutations.some((mutation) => {
var _a, _b;
if (mutation.type !== "childList" || mutation.removedNodes.length === 0) {
return false;
}
for (const node of mutation.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (((_a = node.classList) == null ? void 0 : _a.contains("rich-foot")) || ((_b = node.querySelector) == null ? void 0 : _b.call(node, ".rich-foot[data-rich-foot]"))) {
return true;
}
}
}
return false;
});
if (footerRemoved) {
observerCallback();
}
});
observer.observe(container, {
childList: true,
subtree: false
// Only watch direct children for better performance
});
this.observers.set(container, {
observer,
cleanup: () => {
if (timeoutId) clearTimeout(timeoutId);
if (rafId) cancelAnimationFrame(rafId);
if (recheckTimeoutId) clearTimeout(recheckTimeoutId);
}
});
}
/**
* Disconnect observer for a container
* @param {HTMLElement} container - The container
*/
disconnectObserver(container) {
const observerData = this.observers.get(container);
if (observerData) {
if (observerData.cleanup) {
observerData.cleanup();
}
if (observerData.observer) {
observerData.observer.disconnect();
}
this.observers.delete(container);
}
this.cancelPendingUpdate(container);
}
/**
* Disconnect all observers
*/
disconnectAllObservers() {
this.observers.forEach((observerData, container) => {
if (observerData.cleanup) {
observerData.cleanup();
}
if (observerData.observer) {
observerData.observer.disconnect();
}
this.cancelPendingUpdate(container);
});
this.observers.clear();
this.pendingUpdates.clear();
}
};
// src/main.js
var RICH_FOOT_CSS_VARS = [
"--rich-foot-border-width",
"--rich-foot-border-style",
"--rich-foot-border-opacity",
"--rich-foot-border-radius",
"--rich-foot-dates-opacity",
"--rich-foot-links-opacity",
"--rich-foot-date-color",
"--rich-foot-border-color",
"--rich-foot-link-color",
"--rich-foot-link-background",
"--rich-foot-link-border-color",
"--rich-foot-content-max-width"
];
var RichFootPlugin = class extends import_obsidian4.Plugin {
async onload() {
await this.loadSettings();
this.dataManager = new RichFootDataManager(this.app);
this.renderer = new RichFootRenderer(this);
this.viewManager = new RichFootViewManager(this);
this.updateCSSProperties();
await this.checkVersion();
this.addSettingTab(new RichFootSettingTab(this.app, this));
this.debounceTimer = null;
this.updateRafId = null;
this.app.workspace.onLayoutReady(() => {
this.registerWorkspaceEvents();
this.updateActiveView(true);
});
}
/**
* Register all workspace events using proper Obsidian registration
*/
registerWorkspaceEvents() {
this.registerEvent(
this.app.workspace.on("layout-change", () => {
this.updateActiveView(true);
})
);
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => {
const isEditMode = this.isEditMode();
this.updateActiveView(!isEditMode);
})
);
this.registerEvent(
this.app.workspace.on("file-open", () => {
this.updateActiveView(true);
})
);
this.registerEvent(
this.app.workspace.on("editor-change", () => {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.updateActiveView();
}, this.settings.updateDelay);
})
);
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
if (this.shouldUpdateForMetadataChange(file)) {
const isEditMode = this.isEditMode();
this.updateActiveView(!isEditMode);
}
})
);
}
/**
* Update the active view's footer
* @param {boolean} immediate - If true, update immediately; otherwise use RAF
*/
updateActiveView(immediate = false) {
if (this.updateRafId) {
cancelAnimationFrame(this.updateRafId);
this.updateRafId = null;
}
const performUpdate = async () => {
const views = [];
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof import_obsidian4.MarkdownView && leaf.view.file) {
views.push(leaf.view);
}
});
for (const view of views) {
try {
await this.viewManager.attachToView(view);
} catch (error) {
console.error("Rich Foot update error:", error);
}
}
};
if (immediate) {
performUpdate();
} else {
this.updateRafId = requestAnimationFrame(performUpdate);
}
}
/**
* Check if should update for metadata change
* @private
*/
shouldUpdateForMetadataChange(file) {
const activeView = this.app.workspace.getActiveViewOfType(import_obsidian4.MarkdownView);
if (!activeView || activeView.file !== file) return false;
const cache = this.app.metadataCache.getFileCache(file);
if (!(cache == null ? void 0 : cache.frontmatter)) return false;
const hasCustomCreated = this.settings.customCreatedDateProp && this.settings.customCreatedDateProp in cache.frontmatter;
const hasCustomModified = this.settings.customModifiedDateProp && this.settings.customModifiedDateProp in cache.frontmatter;
return hasCustomCreated || hasCustomModified;
}
/**
* Check if current view is in edit mode
* @private
*/
isEditMode() {
var _a, _b;
const activeView = this.app.workspace.getActiveViewOfType(import_obsidian4.MarkdownView);
if (!activeView) return false;
const mode = (_b = (_a = activeView.getMode) == null ? void 0 : _a.call(activeView)) != null ? _b : activeView.mode;
return mode === "source";
}
/**
* Update all CSS custom properties
*/
updateCSSProperties() {
const properties = {
"--rich-foot-border-width": `${this.settings.borderWidth}px`,
"--rich-foot-border-style": this.settings.borderStyle,
"--rich-foot-border-opacity": this.settings.borderOpacity,
"--rich-foot-border-radius": `${this.settings.borderRadius}px`,
"--rich-foot-dates-opacity": this.settings.datesOpacity,
"--rich-foot-links-opacity": this.settings.linksOpacity,
"--rich-foot-date-color": this.settings.dateColor,
"--rich-foot-border-color": this.settings.borderColor,
"--rich-foot-link-color": this.settings.linkColor,
"--rich-foot-link-background": this.settings.linkBackgroundColor,
"--rich-foot-link-border-color": this.settings.linkBorderColor,
"--rich-foot-content-max-width": `${this.settings.footerMaxWidth}px`
};
Object.entries(properties).forEach(([property, value]) => {
document.documentElement.style.setProperty(property, value);
});
document.body.setAttribute("data-rich-foot-width", this.settings.footerWidth);
}
/**
* Update rich foot (called from settings)
*/
async updateRichFoot() {
this.updateCSSProperties();
this.updateActiveView(true);
}
/**
* Load plugin settings
*/
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
if (!Array.isArray(this.settings.excludedFolders)) {
this.settings.excludedFolders = [];
}
if (!Array.isArray(this.settings.excludedParentSelectors)) {
this.settings.excludedParentSelectors = [];
}
}
/**
* Save plugin settings
*/
async saveSettings() {
await this.saveData(this.settings);
}
/**
* Check version and show release notes
*/
async checkVersion() {
const currentVersion = this.manifest.version;
const lastVersion = this.settings.lastVersion;
const shouldShow = this.settings.showReleaseNotes && (!lastVersion || lastVersion !== currentVersion);
if (shouldShow) {
const notes = await this.getReleaseNotes(currentVersion);
new ReleaseNotesModal(this.app, this, currentVersion, notes).open();
this.settings.lastVersion = currentVersion;
await this.saveSettings();
}
}
/**
* Get release notes
*/
async getReleaseNotes(version) {
return releaseNotes;
}
/**
* Plugin cleanup
*/
onunload() {
if (this.viewManager) {
this.viewManager.disconnectAllObservers();
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.updateRafId) {
cancelAnimationFrame(this.updateRafId);
this.updateRafId = null;
}
document.querySelectorAll("[data-rich-foot]").forEach((el) => el.remove());
RICH_FOOT_CSS_VARS.forEach((property) => {
document.documentElement.style.removeProperty(property);
});
document.body.removeAttribute("data-rich-foot-width");
}
};
var main_default = RichFootPlugin;
/* nosourcemap */