marsbatya_tg-emoji-search/main.ts

560 lines
No EOL
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, number>;
customEmojiMappings: Record<string, string>;
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<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));
}
async loadWasmModule(): Promise<InitOutput> {
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<string> {
private emojiSearch: EmojiSearch | null = null;
private settings: EmojiSuggesterPluginSettings;
private plugin: EmojiSuggesterPlugin;
private wasmReady = false;
private initPromise: Promise<void> | 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<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;
}
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<string, string>();
// 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}`);
});
}
}
}