From 37a35a81ea9fbfba474a463c4021915b02e70102 Mon Sep 17 00:00:00 2001 From: chetsCMD <104981866+chetsCMD@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:23:45 +0300 Subject: [PATCH] =?UTF-8?q?2.2.0=20=E2=80=94=20global=20dictionaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.js | 590 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 558 insertions(+), 32 deletions(-) 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;