import { App, Editor, EditorPosition, EditorSuggest, EditorSuggestContext, EditorSuggestTriggerInfo, MarkdownView, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; import init, { EmojiSearch } from './pkg/emoji_search_fixed.js'; import emojiDataEnglish from "./emoji_data_english.json"; import emojiDataRussian from "./emoji_data_russian.json"; import emojiDataSpanish from "./emoji_data_spanish.json"; import wasmDataUrl from "./pkg/emoji_search_bg.wasm"; // --- Type Definitions --- interface EmojiSearchWasm { new(): EmojiSearch; } interface InitOutput { EmojiSearch: EmojiSearchWasm; } interface EmojiSuggesterPluginSettings { chosenLanguages: string[]; triggerChar: string; showKeywords: boolean; emojiPopularity: Record; customEmojiMappings: Record; lazyLoadWasm: boolean; } const DEFAULT_SETTINGS: EmojiSuggesterPluginSettings = { chosenLanguages: ['english'], triggerChar: ':', showKeywords: true, emojiPopularity: {}, customEmojiMappings: {}, lazyLoadWasm: true, }; const AVAILABLE_LANGUAGES = { english: 'English', russian: 'Russian', spanish: 'Spanish' }; const RANDOM_EMOJIS = ['😀', '😂', '🥰', '😎', '🤔', '👍', '🎉', '✨', '🔥', '❤️']; async function loadEmojiData() { // The JSON is already inlined as objects return { english: emojiDataEnglish, russian: emojiDataRussian, spanish: emojiDataSpanish }; } // --- Main Plugin Class --- export default class EmojiSuggesterPlugin extends Plugin { settings: EmojiSuggesterPluginSettings; private emojiSearch: EmojiSearch | null = null; async onload() { await this.loadSettings(); if (!this.settings.lazyLoadWasm) { try { await this.loadWasmModule(); this.emojiSearch = new EmojiSearch(); await this.reinitializeEmojiSearch(this.emojiSearch); console.log('Emoji suggester plugin initialized successfully'); } catch (error) { console.error('Failed to initialize WASM:', error); new Notice('Failed to initialize Emoji suggester plugin'); } } // Add a settings tab for the plugin this.addSettingTab(new EmojiSuggesterSettingTab(this.app, this)); this.registerEditorSuggest(new EmojiSuggester(this.app, this, this.emojiSearch)); // Create a ribbon icon with a tooltip const ribbonIconEl = this.addRibbonIcon('smile', 'Emoji suggester', () => { new Notice(`Type "${this.settings.triggerChar}" followed by a keyword to suggest emojis`); }); ribbonIconEl.addClass('emoji-suggester-ribbon-class'); // Add command to insert a random emoji this.addCommand({ id: 'insert-random-emoji', name: 'Insert random emoji', editorCallback: (editor: Editor) => { const randomEmoji = RANDOM_EMOJIS[Math.floor(Math.random() * RANDOM_EMOJIS.length)]; editor.replaceSelection(randomEmoji); } }); } async reinitializeEmojiSearch(emojiSearch: EmojiSearch): Promise { const allEmojiData = await loadEmojiData(); const filteredData: Record = {}; for (const lang of this.settings.chosenLanguages) { if (allEmojiData[lang as keyof typeof allEmojiData]) { filteredData[lang] = allEmojiData[lang as keyof typeof allEmojiData]; } } emojiSearch.initialize(JSON.stringify(filteredData)); } async loadWasmModule(): Promise { try { const wasmModule = await import('./pkg/emoji_search_fixed.js'); const wasmResponse = await fetch(wasmDataUrl as unknown as string);; if (!wasmResponse.ok) { throw new Error(`Failed to fetch WASM from ${wasmDataUrl}: ${wasmResponse.statusText}`); } const wasmBytes = await wasmResponse.arrayBuffer(); return await wasmModule.default(wasmBytes); } catch (error) { console.error('Error loading WASM module:', error); throw error; } } onunload() { // freeing WASM resources if (this.emojiSearch) { this.emojiSearch.free(); } } async loadSettings() { const loadedSettings = await this.loadData() || {}; // Migration: convert old defaultLanguage to chosenLanguages if (loadedSettings.defaultLanguage && !loadedSettings.chosenLanguages) { loadedSettings.chosenLanguages = [loadedSettings.defaultLanguage]; delete loadedSettings.defaultLanguage; } this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings }; } async saveSettings() { await this.saveData(this.settings); // Reinitialize emoji search when settings change if (this.emojiSearch) { await this.reinitializeEmojiSearch(this.emojiSearch); } } } class EmojiSuggester extends EditorSuggest { private emojiSearch: EmojiSearch | null = null; private settings: EmojiSuggesterPluginSettings; private plugin: EmojiSuggesterPlugin; private wasmReady = false; private initPromise: Promise | null = null; constructor(app: App, plugin: EmojiSuggesterPlugin, emojiSearch: EmojiSearch | null = null) { super(app); this.plugin = plugin; this.settings = plugin.settings; this.emojiSearch = emojiSearch; this.wasmReady = this.emojiSearch != null; } private async ensureWasmReady(): Promise { if (this.wasmReady) return; if (!this.plugin.settings.lazyLoadWasm) return; if (this.initPromise) return this.initPromise; this.initPromise = (async () => { await this.plugin.loadWasmModule(); this.emojiSearch = new EmojiSearch(); await this.plugin.reinitializeEmojiSearch(this.emojiSearch); console.log('WASM initialized successfully with emoji data'); this.wasmReady = true; })(); return this.initPromise; } onTrigger(cursor: EditorPosition, editor: Editor): EditorSuggestTriggerInfo | null { const line = editor.getLine(cursor.line); const subString = line.substring(0, cursor.ch); const escapedTrigger = this.settings.triggerChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const triggerRegex = new RegExp(`${escapedTrigger}([a-zA-Zа-яА-Яñáéíóúü][a-zA-Zа-яА-Яñáéíóúü ]*)$`); const match = subString.match(triggerRegex); if (!match) return null; return { start: { line: cursor.line, ch: subString.lastIndexOf(this.settings.triggerChar) }, end: cursor, query: match[1]?.trim() || '' }; } getSuggestions(context: EditorSuggestContext): string[] { if (!this.wasmReady || !this.emojiSearch) { this.ensureWasmReady(); return []; } if (!context.query || !this.emojiSearch) return []; try { // Search across all chosen languages // Detect language based on query characters and filter chosen languages const detectedLanguages = this.settings.chosenLanguages.filter(lang => { if (/[а-яА-Я]/.test(context.query)) { return lang === 'russian'; } else if (/[ñáéíóúü¿¡]/.test(context.query)) { return lang === 'spanish'; } else { // Default to non-Cyrillic languages for Latin script return lang !== 'russian'; } }); // If no languages match the detected script, fall back to all chosen languages const languagesToSearch = detectedLanguages.length > 0 ? detectedLanguages : this.settings.chosenLanguages; // Search only the filtered languages const results: [string, string[]][] = JSON.parse( this.emojiSearch.search_multiple(context.query, JSON.stringify(languagesToSearch)) ); const emojiMap = new Map(); // Add emojis from WASM search results for (const [keyword, emojis] of results) { for (const emoji of emojis) { if (!emojiMap.has(emoji)) { emojiMap.set(emoji, keyword); } } } // Add custom emoji mappings that match the query const query = context.query.toLowerCase(); for (const [keyword, emoji] of Object.entries(this.plugin.settings.customEmojiMappings)) { if (keyword.toLowerCase().includes(query)) { emojiMap.set(emoji, keyword); } } const emojisWithKeywords = Array.from(emojiMap.entries()); // Sort by popularity emojisWithKeywords.sort((a, b) => { const popA = this.plugin.settings.emojiPopularity[a[0]] || 0; const popB = this.plugin.settings.emojiPopularity[b[0]] || 0; return popB - popA; }); if (this.settings.showKeywords) { return emojisWithKeywords.map( ([emoji, keyword]) => `${emoji} (${keyword})` ); } else { return emojisWithKeywords.map(([emoji, _]) => emoji); } } catch (error) { console.error('Error in emoji search:', error); return []; } } renderSuggestion(value: string, el: HTMLElement): void { el.empty(); if (this.settings.showKeywords) { // Render emoji with keyword (vertical list) const matches = value.match(/^(.*) \((.*)\)$/); if (matches) { const [, emoji, keyword] = matches; const container = document.createElement('div'); container.addClass('emoji-suggestion-item'); const emojiSpan = document.createElement('span'); emojiSpan.addClass('emoji-suggestion-emoji'); emojiSpan.textContent = emoji; const keywordSpan = document.createElement('span'); keywordSpan.addClass('emoji-suggestion-keyword'); keywordSpan.textContent = keyword; container.appendChild(emojiSpan); container.appendChild(keywordSpan); el.appendChild(container); } else { el.setText(value); } } else { // Render only the emoji const emojiSpan = document.createElement('span'); emojiSpan.addClass('emoji-suggestion-emoji'); emojiSpan.textContent = value; el.appendChild(emojiSpan); } } selectSuggestion(value: string, evt: MouseEvent | KeyboardEvent): void { // When only emojis are shown, the value is the emoji itself. const emoji = this.settings.showKeywords ? value.split(' ')[0] : value; const { start, end } = this.context!; this.context!.editor.replaceRange(emoji, start, end); this.updateEmojiPopularity(emoji); } private updateEmojiPopularity(emoji: string): void { const settings = this.plugin.settings; if (!settings.emojiPopularity[emoji]) { settings.emojiPopularity[emoji] = 0; } settings.emojiPopularity[emoji]++; this.plugin.saveSettings(); } } class EmojiSuggesterSettingTab extends PluginSettingTab { plugin: EmojiSuggesterPlugin; constructor(app: App, plugin: EmojiSuggesterPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const { containerEl } = this; containerEl.empty(); new Setting(containerEl).setName('General').setHeading(); new Setting(containerEl) .setName('Lazy load emoji engine') .setDesc('Load the emoji search engine on first use instead of at startup. Reduces Obsidian startup time but adds a small delay the first time you trigger suggestions.') .addToggle(toggle => toggle .setValue(this.plugin.settings.lazyLoadWasm) .onChange(async (value) => { this.plugin.settings.lazyLoadWasm = value; await this.plugin.saveSettings(); new Notice('Restart Obsidian for this change to take effect'); })); new Setting(containerEl) .setName('Chosen languages') .setDesc('Select which languages to enable for emoji search') .setClass('emoji-language-setting'); // Create checkboxes for each available language const languageContainer = containerEl.createDiv('language-checkbox-container'); for (const [langCode, langName] of Object.entries(AVAILABLE_LANGUAGES)) { const checkboxContainer = languageContainer.createDiv('language-checkbox-item'); const checkbox = checkboxContainer.createEl('input'); checkbox.type = 'checkbox'; checkbox.id = `lang-${langCode}`; checkbox.checked = this.plugin.settings.chosenLanguages.includes(langCode); const label = checkboxContainer.createEl('label'); label.setAttribute('for', `lang-${langCode}`); label.textContent = langName; checkbox.addEventListener('change', async (e) => { const target = e.target as HTMLInputElement; if (target.checked) { if (!this.plugin.settings.chosenLanguages.includes(langCode)) { this.plugin.settings.chosenLanguages.push(langCode); } } else { const index = this.plugin.settings.chosenLanguages.indexOf(langCode); if (index > -1) { this.plugin.settings.chosenLanguages.splice(index, 1); } } // Ensure at least one language is selected if (this.plugin.settings.chosenLanguages.length === 0) { this.plugin.settings.chosenLanguages.push('english'); // Update the english checkbox const englishCheckbox = containerEl.querySelector('#lang-english') as HTMLInputElement; if (englishCheckbox) englishCheckbox.checked = true; new Notice('At least one language must be selected. English has been re-enabled.'); } await this.plugin.saveSettings(); }); } new Setting(containerEl) .setName('Trigger character') .setDesc('Character that triggers the emoji suggestions') .addText(text => text .setPlaceholder(':') .setValue(this.plugin.settings.triggerChar) .onChange(async (value) => { if (value) { this.plugin.settings.triggerChar = value; await this.plugin.saveSettings(); } })); new Setting(containerEl) .setName('Show keywords in suggestions') .setDesc('Toggle whether to display the keyword alongside the emoji') .addToggle(toggle => toggle .setValue(this.plugin.settings.showKeywords) .onChange(async (value) => { this.plugin.settings.showKeywords = value; await this.plugin.saveSettings(); })); // Add section for custom emoji mappings new Setting(containerEl).setName('Custom emoji mappings').setHeading(); // Display existing custom mappings const customMappingsContainer = containerEl.createDiv('custom-emoji-mappings-container'); this.renderCustomMappings(customMappingsContainer); // Add new mapping controls const newMappingContainer = containerEl.createDiv('new-emoji-mapping-container'); const keywordInput = newMappingContainer.createEl('input'); keywordInput.type = 'text'; keywordInput.placeholder = 'Keyword'; keywordInput.classList.add('custom-emoji-keyword-input'); const emojiInput = newMappingContainer.createEl('input'); emojiInput.type = 'text'; emojiInput.placeholder = 'Emoji'; emojiInput.classList.add('custom-emoji-input'); const addButton = newMappingContainer.createEl('button'); addButton.setText('Add'); addButton.classList.add('custom-emoji-add-button'); addButton.addEventListener('click', async () => { const keyword = keywordInput.value.trim(); const emoji = emojiInput.value.trim(); if (keyword && emoji) { this.plugin.settings.customEmojiMappings[keyword] = emoji; await this.plugin.saveSettings(); // Clear inputs keywordInput.value = ''; emojiInput.value = ''; // Refresh the mappings display this.renderCustomMappings(customMappingsContainer); new Notice(`Added custom mapping: ${keyword} → ${emoji}`); } else { new Notice('Both keyword and emoji are required'); } }); // Reset Custom Mappings button new Setting(containerEl) .setName('Reset custom emoji mappings') .setDesc('Remove all your custom emoji mappings') .addButton(button => button .setButtonText('Reset') .onClick(async () => { this.plugin.settings.customEmojiMappings = {}; await this.plugin.saveSettings(); this.renderCustomMappings(customMappingsContainer); new Notice('Custom emoji mappings have been reset'); })); new Setting(containerEl) .setName('Reset emoji popularity') .setDesc('Reset your emoji usage statistics') .addButton(button => button .setButtonText('Reset') .onClick(async () => { this.plugin.settings.emojiPopularity = {}; await this.plugin.saveSettings(); new Notice('Emoji popularity statistics have been reset'); // Reset the rendered top 10 list const topEmojisEl = containerEl.querySelector('.emoji-popularity-stats'); if (topEmojisEl) { topEmojisEl.removeChild(topEmojisEl.querySelector('.emoji-top-list')!); topEmojisEl.createEl('p', { text: 'No emojis used yet' }); } })); // Display top 10 most used emojis const topEmojisEl = containerEl.createDiv(); topEmojisEl.addClass('emoji-popularity-stats'); topEmojisEl.createEl('h2', { text: 'Your most used emojis' }); const popularityEntries = Object.entries(this.plugin.settings.emojiPopularity); popularityEntries.sort((a, b) => b[1] - a[1]); if (popularityEntries.length === 0) { topEmojisEl.createEl('p', { text: 'No emojis used yet' }); } else { const topList = topEmojisEl.createEl('div'); topList.addClass('emoji-top-list'); // Take top 10 or less const topEmojis = popularityEntries.slice(0, 10); for (const [emoji, count] of topEmojis) { const item = topList.createEl('div'); item.addClass('emoji-top-item'); const emojiSpan = item.createEl('span'); emojiSpan.addClass('emoji-top-symbol'); emojiSpan.textContent = emoji; const countSpan = item.createEl('span'); countSpan.addClass('emoji-top-count'); countSpan.textContent = `Used ${count} time${count !== 1 ? 's' : ''}`; } } } private renderCustomMappings(containerEl: HTMLElement): void { containerEl.empty(); const mappings = this.plugin.settings.customEmojiMappings; const entries = Object.entries(mappings); if (entries.length === 0) { containerEl.createEl('p', { text: 'No custom emoji mappings yet' }); return; } const table = containerEl.createEl('table'); table.classList.add('custom-emoji-table'); // Create header row const headerRow = table.createEl('tr'); headerRow.createEl('th', { text: 'Keyword' }); headerRow.createEl('th', { text: 'Emoji' }); headerRow.createEl('th', { text: 'Action' }); // Create rows for each mapping for (const [keyword, emoji] of entries) { const row = table.createEl('tr'); row.createEl('td', { text: keyword }); row.createEl('td', { text: emoji }); const actionCell = row.createEl('td'); const deleteButton = actionCell.createEl('button'); deleteButton.setText('Delete'); deleteButton.classList.add('custom-emoji-delete-button'); deleteButton.addEventListener('click', async () => { delete this.plugin.settings.customEmojiMappings[keyword]; await this.plugin.saveSettings(); this.renderCustomMappings(containerEl); new Notice(`Removed custom mapping: ${keyword}`); }); } } }