marsbatya_tg-emoji-search/main.ts

560 lines
18 KiB
TypeScript
Raw Normal View History

2025-03-08 16:08:13 +00:00
import {
App,
Editor,
EditorPosition,
EditorSuggest,
EditorSuggestContext,
EditorSuggestTriggerInfo,
MarkdownView,
Notice,
Plugin,
PluginSettingTab,
Setting
} from 'obsidian';
2025-03-09 15:20:25 +00:00
2025-03-08 16:08:13 +00:00
import init, { EmojiSearch } from './pkg/emoji_search_fixed.js';
2025-03-09 15:20:25 +00:00
import emojiDataEnglish from "./emoji_data_english.json";
import emojiDataRussian from "./emoji_data_russian.json";
import emojiDataSpanish from "./emoji_data_spanish.json";
2025-03-09 15:20:25 +00:00
import wasmDataUrl from "./pkg/emoji_search_bg.wasm";
2025-03-08 16:08:13 +00:00
// --- Type Definitions ---
2025-03-08 15:51:35 +00:00
interface EmojiSearchWasm {
new(): EmojiSearch;
}
interface InitOutput {
EmojiSearch: EmojiSearchWasm;
}
interface EmojiSuggesterPluginSettings {
chosenLanguages: string[];
2025-03-08 15:51:35 +00:00
triggerChar: string;
2025-03-09 08:47:39 +00:00
showKeywords: boolean;
emojiPopularity: Record<string, number>;
customEmojiMappings: Record<string, string>;
lazyLoadWasm: boolean;
2025-03-08 15:51:35 +00:00
}
const DEFAULT_SETTINGS: EmojiSuggesterPluginSettings = {
chosenLanguages: ['english'],
triggerChar: ':',
showKeywords: true,
emojiPopularity: {},
customEmojiMappings: {},
lazyLoadWasm: true,
2025-03-08 16:08:13 +00:00
};
const AVAILABLE_LANGUAGES = {
english: 'English',
russian: 'Russian',
spanish: 'Spanish'
};
2025-03-08 16:08:13 +00:00
const RANDOM_EMOJIS = ['😀', '😂', '🥰', '😎', '🤔', '👍', '🎉', '✨', '🔥', '❤️'];
2025-03-09 15:20:25 +00:00
async function loadEmojiData() {
// The JSON is already inlined as objects
return { english: emojiDataEnglish, russian: emojiDataRussian, spanish: emojiDataSpanish };
2025-03-08 15:51:35 +00:00
}
2025-03-08 16:08:13 +00:00
// --- Main Plugin Class ---
export default class EmojiSuggesterPlugin extends Plugin {
2025-03-08 15:51:35 +00:00
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');
}
}
2025-03-08 16:08:13 +00:00
// Add a settings tab for the plugin
2025-03-08 15:51:35 +00:00
this.addSettingTab(new EmojiSuggesterSettingTab(this.app, this));
this.registerEditorSuggest(new EmojiSuggester(this.app, this, this.emojiSearch));
2025-03-08 15:51:35 +00:00
2025-03-08 16:08:13 +00:00
// Create a ribbon icon with a tooltip
2025-03-24 11:50:29 +00:00
const ribbonIconEl = this.addRibbonIcon('smile', 'Emoji suggester', () => {
2025-03-08 16:08:13 +00:00
new Notice(`Type "${this.settings.triggerChar}" followed by a keyword to suggest emojis`);
2025-03-08 15:51:35 +00:00
});
ribbonIconEl.addClass('emoji-suggester-ribbon-class');
2025-03-08 16:08:13 +00:00
// Add command to insert a random emoji
2025-03-08 15:51:35 +00:00
this.addCommand({
id: 'insert-random-emoji',
name: 'Insert random emoji',
2025-03-08 16:08:13 +00:00
editorCallback: (editor: Editor) => {
const randomEmoji = RANDOM_EMOJIS[Math.floor(Math.random() * RANDOM_EMOJIS.length)];
2025-03-08 15:51:35 +00:00
editor.replaceSelection(randomEmoji);
}
});
}
async reinitializeEmojiSearch(emojiSearch: EmojiSearch): Promise<void> {
const allEmojiData = await loadEmojiData();
const filteredData: Record<string, any> = {};
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));
}
2025-03-08 15:51:35 +00:00
async loadWasmModule(): Promise<InitOutput> {
try {
const wasmModule = await import('./pkg/emoji_search_fixed.js');
2025-03-09 15:20:25 +00:00
const wasmResponse = await fetch(wasmDataUrl as unknown as string);;
2025-03-08 15:51:35 +00:00
if (!wasmResponse.ok) {
2025-03-09 15:20:25 +00:00
throw new Error(`Failed to fetch WASM from ${wasmDataUrl}: ${wasmResponse.statusText}`);
2025-03-08 15:51:35 +00:00
}
const wasmBytes = await wasmResponse.arrayBuffer();
return await wasmModule.default(wasmBytes);
} catch (error) {
console.error('Error loading WASM module:', error);
throw error;
}
}
onunload() {
2025-03-09 13:07:16 +00:00
// freeing WASM resources
if (this.emojiSearch) {
this.emojiSearch.free();
}
2025-03-08 15:51:35 +00:00
}
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 };
2025-03-08 15:51:35 +00:00
}
async saveSettings() {
await this.saveData(this.settings);
// Reinitialize emoji search when settings change
if (this.emojiSearch) {
await this.reinitializeEmojiSearch(this.emojiSearch);
}
2025-03-08 15:51:35 +00:00
}
}
class EmojiSuggester extends EditorSuggest<string> {
private emojiSearch: EmojiSearch | null = null;
2025-03-08 15:51:35 +00:00
private settings: EmojiSuggesterPluginSettings;
2025-03-09 08:47:39 +00:00
private plugin: EmojiSuggesterPlugin;
private wasmReady = false;
private initPromise: Promise<void> | null = null;
2025-03-08 15:51:35 +00:00
constructor(app: App, plugin: EmojiSuggesterPlugin, emojiSearch: EmojiSearch | null = null) {
2025-03-08 15:51:35 +00:00
super(app);
2025-03-09 08:47:39 +00:00
this.plugin = plugin;
this.settings = plugin.settings;
this.emojiSearch = emojiSearch;
this.wasmReady = this.emojiSearch != null;
2025-03-08 15:51:35 +00:00
}
private async ensureWasmReady(): Promise<void> {
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;
}
2025-03-08 16:08:13 +00:00
onTrigger(cursor: EditorPosition, editor: Editor): EditorSuggestTriggerInfo | null {
2025-03-08 15:51:35 +00:00
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а-яА-Яñáéíóúü ]*)$`);
2025-03-08 15:51:35 +00:00
const match = subString.match(triggerRegex);
if (!match) return null;
return {
2025-03-08 16:08:13 +00:00
start: { line: cursor.line, ch: subString.lastIndexOf(this.settings.triggerChar) },
2025-03-08 15:51:35 +00:00
end: cursor,
2025-03-08 16:08:13 +00:00
query: match[1]?.trim() || ''
2025-03-08 15:51:35 +00:00
};
}
2025-03-08 15:51:35 +00:00
getSuggestions(context: EditorSuggestContext): string[] {
if (!this.wasmReady || !this.emojiSearch) {
this.ensureWasmReady();
return [];
}
2025-03-08 16:08:13 +00:00
if (!context.query || !this.emojiSearch) return [];
2025-03-08 15:51:35 +00:00
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))
);
2025-03-08 15:51:35 +00:00
const emojiMap = new Map<string, string>();
// Add emojis from WASM search results
2025-03-08 15:51:35 +00:00
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);
}
}
2025-03-09 08:47:39 +00:00
const emojisWithKeywords = Array.from(emojiMap.entries());
// Sort by popularity
2025-03-09 08:47:39 +00:00
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;
2025-03-09 08:47:39 +00:00
});
if (this.settings.showKeywords) {
2025-03-09 08:47:39 +00:00
return emojisWithKeywords.map(
([emoji, keyword]) => `${emoji} (${keyword})`
);
} else {
2025-03-09 08:47:39 +00:00
return emojisWithKeywords.map(([emoji, _]) => emoji);
}
2025-03-08 15:51:35 +00:00
} catch (error) {
console.error('Error in emoji search:', error);
return [];
}
}
2025-03-08 15:51:35 +00:00
renderSuggestion(value: string, el: HTMLElement): void {
2025-03-10 10:42:00 +00:00
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 {
2025-03-09 13:07:16 +00:00
// Render only the emoji
2025-03-08 15:51:35 +00:00
const emojiSpan = document.createElement('span');
emojiSpan.addClass('emoji-suggestion-emoji');
emojiSpan.textContent = value;
el.appendChild(emojiSpan);
2025-03-08 15:51:35 +00:00
}
}
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;
2025-03-08 15:51:35 +00:00
const { start, end } = this.context!;
this.context!.editor.replaceRange(emoji, start, end);
2025-03-09 08:47:39 +00:00
this.updateEmojiPopularity(emoji);
2025-03-08 15:51:35 +00:00
}
2025-03-09 08:47:39 +00:00
private updateEmojiPopularity(emoji: string): void {
const settings = this.plugin.settings;
if (!settings.emojiPopularity[emoji]) {
settings.emojiPopularity[emoji] = 0;
}
settings.emojiPopularity[emoji]++;
this.plugin.saveSettings();
}
2025-03-08 15:51:35 +00:00
}
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();
2025-03-08 15:51:35 +00:00
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');
}));
2025-03-08 15:51:35 +00:00
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();
});
}
2025-03-08 15:51:35 +00:00
new Setting(containerEl)
2025-03-24 11:50:29 +00:00
.setName('Trigger character')
2025-03-08 15:51:35 +00:00
.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)
2025-03-24 11:50:29 +00:00
.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');
}));
2025-03-09 08:47:39 +00:00
new Setting(containerEl)
2025-03-24 11:50:29 +00:00
.setName('Reset emoji popularity')
2025-03-09 08:47:39 +00:00
.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');
2025-03-24 11:50:29 +00:00
topEmojisEl.createEl('h2', { text: 'Your most used emojis' });
2025-03-09 08:47:39 +00:00
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}`);
});
2025-03-09 08:47:39 +00:00
}
2025-03-08 15:51:35 +00:00
}
2025-03-10 10:51:25 +00:00
}