diff --git a/main.js b/main.js
index 2e2434b..b8a9acb 100644
--- a/main.js
+++ b/main.js
@@ -1,7 +1,145 @@
-const { Plugin, Modal } = require('obsidian');
+const { Plugin, Modal, PluginSettingTab, Setting, Notice, TFolder, TFile } = require('obsidian');
const { EditorView, Decoration, ViewPlugin, WidgetType } = require('@codemirror/view');
const { RangeSetBuilder } = require('@codemirror/state');
+const SYNTAX_REGEX = /\{\/\{([^\/\n]+)\/([^}\n]+)\}\/\}/g;
+
+const DEFAULT_SETTINGS = {
+ dictionaries: [],
+};
+
+function escapeRegex(str) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function generateId() {
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+function pathMatchesScope(dictionary, filePath) {
+ if (!filePath) return false;
+ if (!dictionary.path) return false;
+
+ if (dictionary.type === 'note') {
+ const normalized = dictionary.path.endsWith('.md') ? dictionary.path : `${dictionary.path}.md`;
+ return filePath === dictionary.path || filePath === normalized;
+ }
+
+ if (dictionary.type === 'folder') {
+ const folder = dictionary.path.replace(/^\/+/, '').replace(/\/+$/, '');
+ if (folder === '' || folder === '/') return true;
+ return filePath === folder || filePath.startsWith(folder + '/');
+ }
+
+ return false;
+}
+
+function getAllFolderPaths(app) {
+ const folders = [];
+
+ const recurse = (folder) => {
+ if (folder.path && folder.path !== '/') folders.push(folder.path);
+ folder.children.forEach(child => {
+ if (child instanceof TFolder) recurse(child);
+ });
+ };
+
+ recurse(app.vault.getRoot());
+ folders.unshift('/');
+ return folders;
+}
+
+class PathSuggest {
+ constructor(inputEl, items, onSelect) {
+ this.inputEl = inputEl;
+ this.items = items;
+ this.onSelect = onSelect;
+ this.containerEl = null;
+
+ this.handleInput = () => this.render(this.getMatches());
+ this.handleFocus = () => this.render(this.getMatches());
+ this.handleBlur = () => {
+ window.setTimeout(() => this.close(), 150);
+ };
+ this.handleKeyDown = (e) => {
+ if (e.key === 'Escape') this.close();
+ };
+
+ this.inputEl.addEventListener('input', this.handleInput);
+ this.inputEl.addEventListener('focus', this.handleFocus);
+ this.inputEl.addEventListener('blur', this.handleBlur);
+ this.inputEl.addEventListener('keydown', this.handleKeyDown);
+ }
+
+ getMatches() {
+ const query = this.inputEl.value.toLowerCase();
+ return this.items.filter(item => item.toLowerCase().includes(query)).slice(0, 50);
+ }
+
+ render(matches) {
+ this.close();
+ if (matches.length === 0) return;
+
+ const rect = this.inputEl.getBoundingClientRect();
+ const container = document.createElement('div');
+ container.className = 'tooltip-path-suggest';
+ container.style.position = 'fixed';
+ container.style.zIndex = '2147483647';
+ container.style.left = `${rect.left}px`;
+ container.style.top = `${rect.bottom + 2}px`;
+ container.style.width = `${rect.width}px`;
+ container.style.maxHeight = '220px';
+ container.style.overflowY = 'auto';
+ container.style.backgroundColor = 'var(--background-primary)';
+ container.style.border = '1px solid var(--background-modifier-border)';
+ container.style.borderRadius = '6px';
+ container.style.boxShadow = '0 6px 18px rgba(0, 0, 0, 0.25)';
+
+ matches.forEach(item => {
+ const itemEl = document.createElement('div');
+ itemEl.textContent = item;
+ itemEl.style.padding = '6px 10px';
+ itemEl.style.cursor = 'pointer';
+ itemEl.style.fontSize = '0.9em';
+
+ itemEl.addEventListener('mouseenter', () => {
+ itemEl.style.backgroundColor = 'var(--background-modifier-hover)';
+ });
+ itemEl.addEventListener('mouseleave', () => {
+ itemEl.style.backgroundColor = '';
+ });
+
+ itemEl.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ this.inputEl.value = item;
+ this.inputEl.dispatchEvent(new Event('input'));
+ if (this.onSelect) this.onSelect(item);
+ this.close();
+ });
+
+ container.appendChild(itemEl);
+ });
+
+ document.body.appendChild(container);
+ this.containerEl = container;
+ }
+
+ close() {
+ if (this.containerEl) {
+ this.containerEl.remove();
+ this.containerEl = null;
+ }
+ }
+
+ destroy() {
+ this.close();
+ this.inputEl.removeEventListener('input', this.handleInput);
+ this.inputEl.removeEventListener('focus', this.handleFocus);
+ this.inputEl.removeEventListener('blur', this.handleBlur);
+ this.inputEl.removeEventListener('keydown', this.handleKeyDown);
+ }
+}
+
class TooltipWidget extends WidgetType {
constructor(plugin, word, tooltip) {
super();
@@ -52,32 +190,88 @@ const tooltipViewPlugin = (plugin) => {
buildDecorations(view, plugin) {
const builder = new RangeSetBuilder();
- const regex = /\{\/\{([^\/\n]+)\/([^}\n]+)\}\/\}/g;
const cursorPos = view.state.selection.main.head;
const text = view.state.doc.toString();
+ const ranges = [];
+
let match;
- while ((match = regex.exec(text)) !== null) {
+ SYNTAX_REGEX.lastIndex = 0;
+ while ((match = SYNTAX_REGEX.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (cursorPos < start || cursorPos > end) {
const word = match[1].trim();
let tooltip = match[2].trim().replace(/\}$/, "");
-
+
const lines = tooltip.split('\n').map(line => line.trim()).filter(line => line.length > 0);
tooltip = lines.join(' / ');
- builder.add(start, end, Decoration.replace({
- widget: new TooltipWidget(plugin, word, tooltip),
- }));
+ ranges.push({
+ start,
+ end,
+ deco: Decoration.replace({ widget: new TooltipWidget(plugin, word, tooltip) }),
+ });
}
}
+ const filePath = plugin.getCurrentFilePath(view);
+ const dictMap = plugin.getDictionaryWordsForPath(filePath);
+
+ if (dictMap.size > 0) {
+ const dictRegex = plugin.buildDictionaryRegex(dictMap);
+ if (dictRegex) {
+ dictRegex.lastIndex = 0;
+ let dMatch;
+ while ((dMatch = dictRegex.exec(text)) !== null) {
+ const start = dMatch.index;
+ const end = start + dMatch[0].length;
+
+ const overlaps = ranges.some(r => start < r.end && end > r.start);
+ if (overlaps) continue;
+ if (cursorPos >= start && cursorPos <= end) continue;
+
+ const matchedWord = dMatch[0];
+ const tooltip = dictMap.get(matchedWord.toLowerCase());
+ if (!tooltip) continue;
+
+ ranges.push({
+ start,
+ end,
+ deco: Decoration.mark({
+ class: 'tooltip-word tooltip-dict-word',
+ attributes: { 'data-tooltip': tooltip },
+ }),
+ });
+ }
+ }
+ }
+
+ ranges.sort((a, b) => a.start - b.start);
+ ranges.forEach(r => builder.add(r.start, r.end, r.deco));
+
return builder.finish();
}
},
- { decorations: (v) => v.decorations }
+ {
+ decorations: (v) => v.decorations,
+ eventHandlers: {
+ mouseover(e, view) {
+ const target = e.target;
+ if (target && target.classList && target.classList.contains('tooltip-dict-word')) {
+ const tooltip = target.getAttribute('data-tooltip');
+ if (tooltip) plugin.showTooltip(target, tooltip);
+ }
+ },
+ mouseout(e, view) {
+ const target = e.target;
+ if (target && target.classList && target.classList.contains('tooltip-dict-word')) {
+ plugin.hideTooltip();
+ }
+ },
+ },
+ }
);
};
@@ -146,16 +340,16 @@ class TooltipPlugin extends Plugin {
processMarkdownLine(line) {
line = this.escapeHtml(line);
-
+
line = line.replace(/`([^`]+)`/g, '$1');
line = line.replace(/\*\*([^\*]+)\*\*/g, '$1');
line = line.replace(/\*([^\*]+)\*/g, '$1');
line = line.replace(/==([^=]+)==/g, '$1');
-
+
line = line.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, text, url) => {
return `${text}`;
});
-
+
line = line.replace(/\[\[([^\]]+)\]\]/g, (match, link) => {
const parts = link.split('|');
const href = parts[0];
@@ -172,25 +366,157 @@ class TooltipPlugin extends Plugin {
return div.innerHTML;
}
- onload() {
+ async loadSettings() {
+ const data = await this.loadData();
+ this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
+ if (!Array.isArray(this.settings.dictionaries)) {
+ this.settings.dictionaries = [];
+ }
+ }
+
+ async saveSettings() {
+ await this.saveData(this.settings);
+ }
+
+ getCurrentFilePath(view) {
+ const activeFile = this.app.workspace.getActiveFile();
+ return activeFile ? activeFile.path : null;
+ }
+
+ getDictionaryWordsForPath(filePath) {
+ const map = new Map();
+ if (!filePath || !this.settings) return map;
+
+ for (const dictionary of this.settings.dictionaries) {
+ if (!pathMatchesScope(dictionary, filePath)) continue;
+ for (const entry of dictionary.words || []) {
+ if (!entry.word) continue;
+ map.set(entry.word.trim().toLowerCase(), entry.tooltip);
+ }
+ }
+
+ return map;
+ }
+
+ buildDictionaryRegex(dictMap) {
+ const words = Array.from(dictMap.keys()).filter(w => w.length > 0);
+ if (words.length === 0) return null;
+
+ words.sort((a, b) => b.length - a.length);
+ const pattern = words.map(escapeRegex).join('|');
+ return new RegExp(`(? {
+ let parent = node.parentElement;
+ while (parent && parent !== el) {
+ if (skipTags.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
+ if (parent.classList && parent.classList.contains('tooltip-word')) return NodeFilter.FILTER_REJECT;
+ parent = parent.parentElement;
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ },
+ });
+
+ const textNodes = [];
+ let current;
+ while ((current = walker.nextNode())) {
+ textNodes.push(current);
+ }
+
+ textNodes.forEach(textNode => {
+ const text = textNode.nodeValue;
+ regex.lastIndex = 0;
+ if (!regex.test(text)) return;
+ regex.lastIndex = 0;
+
+ const fragment = document.createDocumentFragment();
+ let lastIndex = 0;
+ let match;
+
+ while ((match = regex.exec(text)) !== null) {
+ const start = match.index;
+ const end = start + match[0].length;
+
+ if (start > lastIndex) {
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)));
+ }
+
+ const matchedWord = match[0];
+ const tooltip = dictMap.get(matchedWord.toLowerCase());
+
+ const span = document.createElement('span');
+ span.className = 'tooltip-word tooltip-dict-word';
+ span.textContent = matchedWord;
+ span.setAttribute('data-tooltip', tooltip || '');
+
+ fragment.appendChild(span);
+ lastIndex = end;
+ }
+
+ if (lastIndex < text.length) {
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
+ }
+
+ textNode.parentNode.replaceChild(fragment, textNode);
+ });
+
+ el.querySelectorAll('.tooltip-dict-word').forEach(wordElement => {
+ wordElement.addEventListener('mouseenter', (e) => {
+ if (this.pinnedTooltip) return;
+ this.showTooltip(e.target, e.target.getAttribute('data-tooltip'));
+ });
+
+ wordElement.addEventListener('mouseleave', (e) => {
+ if (this.pinnedTooltip) return;
+ this.hideTooltip();
+ });
+
+ wordElement.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const tooltipContent = e.target.getAttribute('data-tooltip');
+
+ if (this.pinnedTooltip === e.target) {
+ this.pinnedTooltip = null;
+ this.hideTooltip();
+ } else {
+ this.pinnedTooltip = e.target;
+ this.showTooltip(e.target, tooltipContent);
+ }
+ });
+ });
+ }
+
+ async onload() {
console.log("Tooltip Plugin loaded");
this.tooltipElement = null;
this.pinnedTooltip = null;
+ await this.loadSettings();
+ this.addSettingTab(new TooltipSettingTab(this.app, this));
+
this.registerEditorExtension(tooltipViewPlugin(this));
- this.registerMarkdownPostProcessor((el) => {
- const regex = /\{\/\{([^\/\n]+)\/([^}\n]+)\}\/\}/g;
- el.innerHTML = el.innerHTML.replace(regex, (_, word, tooltip) => {
+ this.registerMarkdownPostProcessor((el, ctx) => {
+ el.innerHTML = el.innerHTML.replace(SYNTAX_REGEX, (_, word, tooltip) => {
tooltip = tooltip.trim().replace(/\}$/, "");
- const uniqueId = `tooltip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
-
+
const lines = tooltip.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const formattedTooltip = lines.join(' / ');
-
+
const escapedWord = this.escapeHtml(word.trim());
-
+
return `
${escapedWord}
@@ -212,7 +538,7 @@ class TooltipPlugin extends Plugin {
wordElement.addEventListener('click', (e) => {
e.stopPropagation();
const tooltipContent = e.target.getAttribute('data-tooltip');
-
+
if (this.pinnedTooltip === e.target) {
this.pinnedTooltip = null;
this.hideTooltip();
@@ -222,10 +548,12 @@ class TooltipPlugin extends Plugin {
}
});
});
+
+ this.decorateDictionaryWordsInElement(el, ctx.sourcePath);
});
this.registerDomEvent(document, 'click', (e) => {
- if (this.pinnedTooltip && !this.pinnedTooltip.contains(e.target) &&
+ if (this.pinnedTooltip && !this.pinnedTooltip.contains(e.target) &&
(!this.tooltipElement || !this.tooltipElement.contains(e.target))) {
this.pinnedTooltip = null;
this.hideTooltip();
@@ -239,11 +567,11 @@ class TooltipPlugin extends Plugin {
const selection = editor.getSelection();
const word = selection || "word";
const description = await this.promptForDescription(word);
-
+
const lines = description.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
-
+
const formattedDescription = lines.join(' / ');
const formatted = `{/{${word}/${formattedDescription}}/}`;
editor.replaceSelection(formatted);
@@ -262,7 +590,7 @@ class TooltipPlugin extends Plugin {
}
const lines = content.split(' / ').map(line => line.trim());
-
+
const processedLines = lines.map(line => {
if (line.match(/<[^>]+>/)) {
return line;
@@ -270,31 +598,31 @@ class TooltipPlugin extends Plugin {
return this.processMarkdownLine(line);
}
});
-
+
const tooltipHTML = processedLines.join('
');
const sanitizedHTML = this.sanitizeHtml(tooltipHTML);
-
+
this.tooltipElement.innerHTML = sanitizedHTML;
-
+
const rect = element.getBoundingClientRect();
const tooltipRect = this.tooltipElement.getBoundingClientRect();
-
+
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
let top = rect.top - tooltipRect.height - 5;
-
+
if (left < 5) left = 5;
if (left + tooltipRect.width > window.innerWidth - 5) {
left = window.innerWidth - tooltipRect.width - 5;
}
-
+
if (top < 5) {
top = rect.bottom + 5;
}
-
+
this.tooltipElement.style.left = left + 'px';
this.tooltipElement.style.top = top + 'px';
this.tooltipElement.classList.add('visible');
-
+
const internalLinks = this.tooltipElement.querySelectorAll('a.internal-link');
const app = this.app;
internalLinks.forEach(link => {
@@ -410,4 +738,202 @@ class TooltipPromptModal extends Modal {
}
}
+class DictionaryWordModal extends Modal {
+ constructor(app, existingEntry, onSubmit) {
+ super(app);
+ this.existingEntry = existingEntry || { word: '', tooltip: '' };
+ this.onSubmit = onSubmit;
+ }
+
+ onOpen() {
+ const { contentEl } = this;
+ contentEl.empty();
+ contentEl.createEl("h2", { text: this.existingEntry.word ? "Edit word" : "Add word" });
+
+ let wordValue = this.existingEntry.word;
+ let tooltipValue = (this.existingEntry.tooltip || '').split(' / ').join('\n');
+
+ new Setting(contentEl)
+ .setName("Word or phrase")
+ .addText(text => {
+ text.setValue(wordValue);
+ text.onChange(v => wordValue = v);
+ text.inputEl.style.width = "100%";
+ });
+
+ new Setting(contentEl)
+ .setName("Tooltip")
+ .addTextArea(textarea => {
+ textarea.setValue(tooltipValue);
+ textarea.onChange(v => tooltipValue = v);
+ textarea.inputEl.style.width = "100%";
+ textarea.inputEl.style.minHeight = "100px";
+ });
+
+ new Setting(contentEl)
+ .addButton(btn => {
+ btn.setButtonText("Save");
+ btn.setCta();
+ btn.onClick(() => {
+ if (!wordValue.trim()) {
+ new Notice("Enter a word or phrase");
+ return;
+ }
+
+ const lines = tooltipValue.split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0);
+ const formattedTooltip = lines.join(' / ');
+
+ this.onSubmit({ word: wordValue.trim(), tooltip: formattedTooltip });
+ this.close();
+ });
+ });
+ }
+
+ onClose() {
+ this.contentEl.empty();
+ }
+}
+
+class TooltipSettingTab extends PluginSettingTab {
+ constructor(app, plugin) {
+ super(app, plugin);
+ this.plugin = plugin;
+ }
+
+ display() {
+ this.display = this.display.bind(this);
+ this.containerEl.empty();
+
+ const { containerEl } = this;
+ containerEl.createEl("h2", { text: "Persistent Dictionaries" });
+ containerEl.createEl("p", {
+ text: "Dictionaries let you automatically apply tooltips to words in a specific note or across an entire folder of notes, without manually inserting {/{ }/} syntax.",
+ });
+
+ new Setting(containerEl)
+ .setName("Add a new dictionary")
+ .addButton(btn => {
+ btn.setButtonText("+ Dictionary");
+ btn.setCta();
+ btn.onClick(async () => {
+ this.plugin.settings.dictionaries.push({
+ id: generateId(),
+ path: '',
+ type: 'folder',
+ words: [],
+ });
+ await this.plugin.saveSettings();
+ this.display();
+ });
+ });
+
+ this.plugin.settings.dictionaries.forEach((dictionary) => {
+ this.renderDictionary(containerEl, dictionary);
+ });
+ }
+
+ renderDictionary(containerEl, dictionary) {
+ const box = containerEl.createDiv({ cls: "tooltip-dict-box" });
+ box.style.border = "1px solid var(--background-modifier-border)";
+ box.style.borderRadius = "8px";
+ box.style.padding = "12px";
+ box.style.marginTop = "16px";
+
+ new Setting(box)
+ .setName("Scope")
+ .addDropdown(dropdown => {
+ dropdown.addOption('folder', 'Folder');
+ dropdown.addOption('note', 'Specific note');
+ dropdown.setValue(dictionary.type);
+ dropdown.onChange(async (value) => {
+ dictionary.type = value;
+ await this.plugin.saveSettings();
+ this.display();
+ });
+ })
+ .addText(text => {
+ text.setPlaceholder(dictionary.type === 'note' ? 'Folder/Note.md' : 'Folder/Subfolder');
+ text.setValue(dictionary.path);
+ text.onChange(async (value) => {
+ dictionary.path = value.trim();
+ await this.plugin.saveSettings();
+ });
+ text.inputEl.style.width = "100%";
+
+ const items = dictionary.type === 'note'
+ ? this.plugin.app.vault.getMarkdownFiles().map(f => f.path)
+ : getAllFolderPaths(this.plugin.app);
+ new PathSuggest(text.inputEl, items, async (value) => {
+ dictionary.path = value;
+ await this.plugin.saveSettings();
+ });
+ })
+ .addExtraButton(btn => {
+ btn.setIcon("trash");
+ btn.setTooltip("Delete dictionary");
+ btn.onClick(async () => {
+ this.plugin.settings.dictionaries = this.plugin.settings.dictionaries.filter(d => d.id !== dictionary.id);
+ await this.plugin.saveSettings();
+ this.display();
+ });
+ });
+
+ const wordsContainer = box.createDiv({ cls: "tooltip-dict-words" });
+ wordsContainer.style.marginTop = "8px";
+
+ (dictionary.words || []).forEach((entry, index) => {
+ const row = wordsContainer.createDiv();
+ row.style.display = "flex";
+ row.style.alignItems = "center";
+ row.style.justifyContent = "space-between";
+ row.style.padding = "6px 0";
+ row.style.borderTop = "1px solid var(--background-modifier-border)";
+
+ const label = row.createDiv();
+ label.style.flexGrow = "1";
+ label.createEl("strong", { text: entry.word });
+ label.createEl("div", { text: entry.tooltip, cls: "tooltip-dict-preview" });
+ label.querySelector(".tooltip-dict-preview").style.color = "var(--text-muted)";
+ label.querySelector(".tooltip-dict-preview").style.fontSize = "0.9em";
+
+ const actions = row.createDiv();
+ const editBtn = actions.createEl("button", { text: "✎" });
+ editBtn.style.marginRight = "6px";
+ editBtn.onclick = () => {
+ const modal = new DictionaryWordModal(this.app, entry, async (updated) => {
+ dictionary.words[index] = updated;
+ await this.plugin.saveSettings();
+ this.display();
+ });
+ modal.open();
+ };
+
+ const deleteBtn = actions.createEl("button", { text: "✕" });
+ deleteBtn.onclick = async () => {
+ dictionary.words.splice(index, 1);
+ await this.plugin.saveSettings();
+ this.display();
+ };
+ });
+
+ const addWordSetting = new Setting(box)
+ .addButton(btn => {
+ btn.setButtonText("+ Word");
+ btn.onClick(() => {
+ const modal = new DictionaryWordModal(this.app, null, async (entry) => {
+ if (!dictionary.words) dictionary.words = [];
+ dictionary.words.push(entry);
+ await this.plugin.saveSettings();
+ this.display();
+ });
+ modal.open();
+ });
+ });
+ addWordSetting.settingEl.style.borderTop = "none";
+ addWordSetting.settingEl.style.marginTop = "8px";
+ }
+}
+
module.exports = TooltipPlugin;