# Custom Widget Development Guide This guide provides comprehensive instructions for creating custom widgets in Trilium Notes. Widgets are fundamental UI components that enable you to extend Trilium's functionality with custom interfaces and behaviors. ## Prerequisites Before developing custom widgets, ensure you have: - Basic knowledge of TypeScript/JavaScript - Understanding of jQuery and DOM manipulation - Familiarity with Trilium's note structure - A development environment with Trilium running locally ## Understanding Widget Architecture ### Widget Hierarchy Trilium's widget system follows a hierarchical structure: ``` Component (base class) └── BasicWidget ├── NoteContextAwareWidget │ ├── TypeWidget (for note type widgets) │ └── RightPanelWidget └── Custom widgets (buttons, containers, etc.) ``` ### Core Widget Classes #### BasicWidget The foundation class for all widgets. Provides basic rendering, positioning, and visibility management. ```typescript import BasicWidget from "../widgets/basic_widget.js"; class MyCustomWidget extends BasicWidget { doRender() { this.$widget = $('
Hello Widget
'); } } ``` #### NoteContextAwareWidget Extends BasicWidget to respond to note changes. Use this when your widget needs to update based on the active note. ```typescript import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js"; class NoteInfoWidget extends NoteContextAwareWidget { async refreshWithNote(note) { if (!note) return; this.$widget.find('.note-title').text(note.title); this.$widget.find('.note-type').text(note.type); } doRender() { this.$widget = $(`
`); } } ``` #### RightPanelWidget Specialized widget for rendering panels in the right sidebar with a consistent card layout. ```typescript import RightPanelWidget from "../widgets/right_panel_widget.js"; class StatisticsWidget extends RightPanelWidget { get widgetTitle() { return "Note Statistics"; } async doRenderBody() { this.$body.html(`
Words: 0
Characters: 0
`); } async refreshWithNote(note) { const content = await note.getContent(); const wordCount = content.split(/\s+/).length; const charCount = content.length; this.$body.find('.word-count span').text(wordCount); this.$body.find('.char-count span').text(charCount); } } ``` ## Widget Lifecycle ### Initialization Phase 1. **Constructor**: Set up initial state and child widgets 2. **render()**: Called to create the widget's DOM structure 3. **doRender()**: Override this to create your widget's HTML ### Update Phase 1. **refresh()**: Called when widget needs updating 2. **refreshWithNote()**: Called for NoteContextAwareWidget when note changes 3. **Event handlers**: Respond to various Trilium events ### Cleanup Phase 1. **cleanup()**: Override to clean up resources, event listeners, etc. 2. **remove()**: Removes widget from DOM ## Event Handling ### Subscribing to Events Widgets can listen to Trilium's event system: ```typescript class EventAwareWidget extends NoteContextAwareWidget { constructor() { super(); // Events are automatically subscribed based on method names } // Called when entities are reloaded async entitiesReloadedEvent({ loadResults }) { console.log('Entities reloaded'); await this.refresh(); } // Called when note content changes async noteContentChangedEvent({ noteId }) { if (this.noteId === noteId) { await this.refresh(); } } // Called when active context changes async activeContextChangedEvent({ noteContext }) { this.noteContext = noteContext; await this.refresh(); } } ``` ### Common Events - `noteSwitched`: Active note changed - `activeContextChanged`: Active tab/context changed - `entitiesReloaded`: Notes, branches, or attributes reloaded - `noteContentChanged`: Note content modified - `noteTypeMimeChanged`: Note type or MIME changed - `frocaReloaded`: Frontend cache reloaded ## State Management ### Local State Store widget-specific state in instance properties: ```typescript class StatefulWidget extends BasicWidget { constructor() { super(); this.isExpanded = false; this.cachedData = null; } toggleExpanded() { this.isExpanded = !this.isExpanded; this.$widget.toggleClass('expanded', this.isExpanded); } } ``` ### Persistent State Use options or attributes for persistent state: ```typescript class PersistentWidget extends NoteContextAwareWidget { async saveState(state) { await server.put('options', { name: 'widgetState', value: JSON.stringify(state) }); } async loadState() { const option = await server.get('options/widgetState'); return option ? JSON.parse(option.value) : {}; } } ``` ## Accessing Trilium APIs ### Frontend Services ```typescript import froca from "../services/froca.js"; import server from "../services/server.js"; import linkService from "../services/link.js"; import toastService from "../services/toast.js"; import dialogService from "../services/dialog.js"; class ApiWidget extends NoteContextAwareWidget { async doRenderBody() { // Access notes const note = await froca.getNote(this.noteId); // Get attributes const attributes = note.getAttributes(); // Create links const $link = await linkService.createLink(note.noteId); // Show notifications toastService.showMessage("Widget loaded"); // Open dialogs const result = await dialogService.confirm("Continue?"); } } ``` ### Server Communication ```typescript class ServerWidget extends BasicWidget { async loadData() { // GET request const data = await server.get('custom-api/data'); // POST request const result = await server.post('custom-api/process', { noteId: this.noteId, action: 'analyze' }); // PUT request await server.put(`notes/${this.noteId}`, { title: 'Updated Title' }); } } ``` ## Styling Widgets ### Inline Styles ```typescript class StyledWidget extends BasicWidget { doRender() { this.$widget = $('
'); this.css('padding', '10px') .css('background-color', '#f0f0f0') .css('border-radius', '4px'); } } ``` ### CSS Classes ```typescript class ClassedWidget extends BasicWidget { doRender() { this.$widget = $('
'); this.class('custom-widget') .class('bordered'); } } ``` ### CSS Blocks ```typescript class CSSBlockWidget extends BasicWidget { doRender() { this.$widget = $('
Content
'); this.cssBlock(` .my-widget { padding: 15px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px; } .my-widget:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } `); } } ``` ## Performance Optimization ### Lazy Loading ```typescript class LazyWidget extends NoteContextAwareWidget { constructor() { super(); this.dataLoaded = false; } async refreshWithNote(note) { if (!this.isVisible()) { return; // Don't load if not visible } if (!this.dataLoaded) { await this.loadExpensiveData(); this.dataLoaded = true; } this.updateDisplay(); } } ``` ### Debouncing Updates ```typescript import SpacedUpdate from "../services/spaced_update.js"; class DebouncedWidget extends NoteContextAwareWidget { constructor() { super(); this.spacedUpdate = new SpacedUpdate(async () => { await this.performUpdate(); }, 500); // 500ms delay } async handleInput(value) { await this.spacedUpdate.scheduleUpdate(); } } ``` ### Caching ```typescript class CachedWidget extends NoteContextAwareWidget { constructor() { super(); this.cache = new Map(); } async getProcessedData(noteId) { if (!this.cache.has(noteId)) { const data = await this.processExpensiveOperation(noteId); this.cache.set(noteId, data); } return this.cache.get(noteId); } cleanup() { this.cache.clear(); } } ``` ## Debugging Widgets ### Console Logging ```typescript class DebugWidget extends BasicWidget { doRender() { console.log('Widget rendering', this.componentId); console.time('render'); this.$widget = $('
'); console.timeEnd('render'); } } ``` ### Error Handling ```typescript class SafeWidget extends NoteContextAwareWidget { async refreshWithNote(note) { try { await this.riskyOperation(); } catch (error) { console.error('Widget error:', error); this.logRenderingError(error); this.$widget.html('
Failed to load
'); } } } ``` ### Development Tools ```typescript class DevWidget extends BasicWidget { doRender() { this.$widget = $('
'); // Add debug information in development if (window.glob.isDev) { this.$widget.attr('data-debug', 'true'); this.$widget.append(`
Component ID: ${this.componentId} Position: ${this.position}
`); } } } ``` ## Complete Example: Note Statistics Widget Here's a complete example implementing a custom note statistics widget: ```typescript import RightPanelWidget from "../widgets/right_panel_widget.js"; import server from "../services/server.js"; import froca from "../services/froca.js"; import toastService from "../services/toast.js"; import SpacedUpdate from "../services/spaced_update.js"; class NoteStatisticsWidget extends RightPanelWidget { constructor() { super(); // Initialize state this.statistics = { words: 0, characters: 0, paragraphs: 0, readingTime: 0, links: 0, images: 0 }; // Debounce updates for performance this.spacedUpdate = new SpacedUpdate(async () => { await this.calculateStatistics(); }, 300); } get widgetTitle() { return "Note Statistics"; } get help() { return { title: "Note Statistics", text: "Displays various statistics about the current note including word count, reading time, and more." }; } async doRenderBody() { this.$body.html(`
Content
Words: 0
Characters: 0
Paragraphs: 0
Reading
Reading time: 0 min
Elements
Links: 0
Images: 0
`); this.cssBlock(` .note-statistics { padding: 10px; } .stat-group { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid var(--main-border-color); } .stat-group:last-child { border-bottom: none; } .stat-group h5 { margin: 0 0 10px 0; color: var(--muted-text-color); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; } .stat-item { display: flex; justify-content: space-between; padding: 5px 0; } .stat-label { color: var(--main-text-color); } .stat-value { font-weight: 600; color: var(--primary-color); } .stat-actions { margin-top: 15px; display: flex; gap: 10px; } .stat-actions .btn { flex: 1; } `); // Bind events this.$body.on('click', '.refresh-stats', () => this.handleRefresh()); this.$body.on('click', '.export-stats', () => this.handleExport()); } async refreshWithNote(note) { if (!note) { this.clearStatistics(); return; } // Schedule statistics calculation await this.spacedUpdate.scheduleUpdate(); } async calculateStatistics() { try { const note = this.note; if (!note) return; const content = await note.getContent(); if (note.type === 'text') { // Parse HTML content const $content = $('
').html(content); const textContent = $content.text(); // Calculate statistics this.statistics.words = this.countWords(textContent); this.statistics.characters = textContent.length; this.statistics.paragraphs = $content.find('p').length; this.statistics.readingTime = Math.ceil(this.statistics.words / 200); this.statistics.links = $content.find('a').length; this.statistics.images = $content.find('img').length; } else if (note.type === 'code') { // For code notes, count lines and characters const lines = content.split('\n'); this.statistics.words = lines.length; // Show lines instead of words this.statistics.characters = content.length; this.statistics.paragraphs = 0; this.statistics.readingTime = 0; this.statistics.links = 0; this.statistics.images = 0; } this.updateDisplay(); } catch (error) { console.error('Failed to calculate statistics:', error); toastService.showError("Failed to calculate statistics"); } } countWords(text) { const words = text.match(/\b\w+\b/g); return words ? words.length : 0; } clearStatistics() { this.statistics = { words: 0, characters: 0, paragraphs: 0, readingTime: 0, links: 0, images: 0 }; this.updateDisplay(); } updateDisplay() { this.$body.find('[data-stat="words"]').text(this.statistics.words); this.$body.find('[data-stat="characters"]').text(this.statistics.characters); this.$body.find('[data-stat="paragraphs"]').text(this.statistics.paragraphs); this.$body.find('[data-stat="readingTime"]').text(`${this.statistics.readingTime} min`); this.$body.find('[data-stat="links"]').text(this.statistics.links); this.$body.find('[data-stat="images"]').text(this.statistics.images); } async handleRefresh() { await this.calculateStatistics(); toastService.showMessage("Statistics refreshed"); } async handleExport() { const note = this.note; if (!note) return; const exportData = { noteId: note.noteId, title: note.title, statistics: this.statistics, timestamp: new Date().toISOString() }; // Create a CSV const csv = [ 'Metric,Value', `Words,${this.statistics.words}`, `Characters,${this.statistics.characters}`, `Paragraphs,${this.statistics.paragraphs}`, `Reading Time,${this.statistics.readingTime} minutes`, `Links,${this.statistics.links}`, `Images,${this.statistics.images}` ].join('\n'); // Download CSV const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `statistics-${note.noteId}.csv`; a.click(); URL.revokeObjectURL(url); toastService.showMessage("Statistics exported"); } async noteContentChangedEvent({ noteId }) { if (this.noteId === noteId) { await this.spacedUpdate.scheduleUpdate(); } } cleanup() { this.$body.off('click'); this.spacedUpdate = null; } } export default NoteStatisticsWidget; ``` ## Best Practices ### 1. Memory Management - Clean up event listeners in `cleanup()` - Clear caches and timers when widget is destroyed - Avoid circular references ### 2. Performance - Use debouncing for frequent updates - Implement lazy loading for expensive operations - Cache computed values when appropriate ### 3. Error Handling - Always wrap async operations in try-catch - Provide user feedback for errors - Log errors for debugging ### 4. User Experience - Show loading states for async operations - Provide clear error messages - Ensure widgets are responsive ### 5. Code Organization - Keep widgets focused on a single responsibility - Extract reusable logic into services - Use composition over inheritance when possible ## Troubleshooting ### Widget Not Rendering - Check `doRender()` creates `this.$widget` - Verify widget is properly registered - Check console for errors ### Events Not Firing - Ensure event method name matches pattern: `${eventName}Event` - Check event is being triggered - Verify widget is active/visible ### State Not Persisting - Use options or attributes for persistence - Check save operations complete successfully - Verify data serialization ### Performance Issues - Profile with browser dev tools - Implement caching and debouncing - Optimize DOM operations ## Next Steps - Explore existing widgets in `/apps/client/src/widgets/` for examples - Review the Frontend Script API documentation - Join the Trilium community for support and sharing widgets