Files
Trilium/docs/Developer Guide/Architecture/Widget-Based-UI-Architecture.md
2025-08-21 15:55:44 +00:00

15 KiB
Vendored

Widget-Based UI Architecture

Trilium's frontend is built on a modular widget system that provides flexibility, reusability, and maintainability. This architecture enables dynamic UI composition and extensibility through custom widgets.

Widget System Overview

graph TB
    subgraph "Widget Hierarchy"
        Component[Component<br/>Base Class]
        BasicWidget[BasicWidget<br/>UI Foundation]
        NoteContextAware[NoteContextAwareWidget<br/>Note-Aware]
        RightPanel[RightPanelWidget<br/>Side Panel]
        TypeWidgets[Type Widgets<br/>Note Type Specific]
        CustomWidgets[Custom Widgets<br/>User Scripts]
    end
    
    Component --> BasicWidget
    BasicWidget --> NoteContextAware
    NoteContextAware --> RightPanel
    NoteContextAware --> TypeWidgets
    NoteContextAware --> CustomWidgets
    
    style Component fill:#e8f5e9
    style BasicWidget fill:#c8e6c9
    style NoteContextAware fill:#a5d6a7

Core Widget Classes

Component (Base Class)

Location: /apps/client/src/components/component.js

The foundational class for all UI components in Trilium.

class Component {
    componentId: string;      // Unique identifier
    children: Component[];    // Child components
    parent: Component | null; // Parent reference
    
    async refresh(): Promise<void>;
    child(...components: Component[]): this;
    handleEvent(name: string, data: any): void;
    trigger(name: string, data?: any): void;
}

BasicWidget

Location: /apps/client/src/widgets/basic_widget.ts

Base class for all UI widgets, providing DOM manipulation and styling capabilities.

export class BasicWidget extends Component {
    protected $widget: JQuery;
    private attrs: Record<string, string>;
    private classes: string[];
    
    // Chaining methods for declarative UI
    id(id: string): this;
    class(className: string): this;
    css(name: string, value: string): this;
    contentSized(): this;
    collapsible(): this;
    filling(): this;
    
    // Conditional rendering
    optChild(condition: boolean, ...components: Component[]): this;
    optCss(condition: boolean, name: string, value: string): this;
    
    // Rendering
    doRender(): JQuery;
}

Usage Example

class MyWidget extends BasicWidget {
    doRender() {
        this.$widget = $('<div>')
            .addClass('my-widget')
            .append($('<h3>').text('Widget Title'));
            
        return this.$widget;
    }
    
    async refreshWithNote(note: FNote) {
        this.$widget.find('h3').text(note.title);
    }
}

// Composing widgets
const container = new FlexContainer('column')
    .id('main-container')
    .css('padding', '10px')
    .filling()
    .child(
        new MyWidget(),
        new ButtonWidget()
            .title('Click Me')
            .onClick(() => console.log('Clicked'))
    );

NoteContextAwareWidget

Location: /apps/client/src/widgets/note_context_aware_widget.ts

Base class for widgets that respond to note context changes.

class NoteContextAwareWidget extends BasicWidget {
    noteContext: NoteContext | null;
    note: FNote | null;
    noteId: string | null;
    notePath: string | null;
    
    // Lifecycle methods
    async refresh(): Promise<void>;
    async refreshWithNote(note: FNote): Promise<void>;
    async noteSwitched(): Promise<void>;
    async activeContextChanged(): Promise<void>;
    
    // Event handlers
    async noteTypeMimeChanged(): Promise<void>;
    async frocaReloaded(): Promise<void>;
    
    // Utility methods
    isNote(noteId: string): boolean;
    get isEnabled(): boolean;
}

Context Management

class MyNoteWidget extends NoteContextAwareWidget {
    async refreshWithNote(note: FNote) {
        // Called when note context changes
        this.$widget.find('.note-title').text(note.title);
        this.$widget.find('.note-type').text(note.type);
        
        // Access note attributes
        const labels = note.getLabels();
        const relations = note.getRelations();
    }
    
    async noteSwitched() {
        // Called when user switches to different note
        console.log(`Switched to note: ${this.noteId}`);
    }
    
    async noteTypeMimeChanged() {
        // React to note type changes
        if (this.note?.type === 'code') {
            this.setupCodeHighlighting();
        }
    }
}

RightPanelWidget

Location: /apps/client/src/widgets/right_panel_widget.ts

Base class for widgets displayed in the right sidebar panel.

abstract class RightPanelWidget extends NoteContextAwareWidget {
    async doRenderBody(): Promise<JQuery>;
    getTitle(): string;
    getIcon(): string;
    getPosition(): number;
    
    async isEnabled(): Promise<boolean> {
        // Override to control visibility
        return true;
    }
}

Creating Right Panel Widgets

class InfoWidget extends RightPanelWidget {
    getTitle() { return "Note Info"; }
    getIcon() { return "info"; }
    getPosition() { return 100; }
    
    async doRenderBody() {
        return $('<div class="info-widget">')
            .append($('<div class="created">'))
            .append($('<div class="modified">'))
            .append($('<div class="word-count">'));
    }
    
    async refreshWithNote(note: FNote) {
        this.$body.find('.created').text(`Created: ${note.dateCreated}`);
        this.$body.find('.modified').text(`Modified: ${note.dateModified}`);
        
        const wordCount = this.calculateWordCount(await note.getContent());
        this.$body.find('.word-count').text(`Words: ${wordCount}`);
    }
}

Type-Specific Widgets

Location: /apps/client/src/widgets/type_widgets/

Each note type has a specialized widget for rendering and editing.

TypeWidget Interface

abstract class TypeWidget extends NoteContextAwareWidget {
    abstract static getType(): string;
    
    // Content management
    async getContent(): Promise<string>;
    async saveContent(content: string): Promise<void>;
    
    // Focus management
    async focus(): Promise<void>;
    async blur(): Promise<void>;
    
    // Cleanup
    async cleanup(): Promise<void>;
}

Common Type Widgets

TextTypeWidget

class TextTypeWidget extends TypeWidget {
    static getType() { return 'text'; }
    
    private textEditor: TextEditor;
    
    async doRender() {
        const $editor = $('<div class="ck-editor">');
        this.textEditor = await TextEditor.create($editor[0], {
            noteId: this.noteId,
            content: await this.note.getContent()
        });
        
        return $editor;
    }
    
    async getContent() {
        return this.textEditor.getData();
    }
}

CodeTypeWidget

class CodeTypeWidget extends TypeWidget {
    static getType() { return 'code'; }
    
    private codeMirror: CodeMirror;
    
    async doRender() {
        const $container = $('<div class="code-editor">');
        
        this.codeMirror = CodeMirror($container[0], {
            value: await this.note.getContent(),
            mode: this.note.mime,
            theme: 'default',
            lineNumbers: true
        });
        
        return $container;
    }
}

Widget Composition

Container Widgets

// Flexible container layouts
class FlexContainer extends BasicWidget {
    constructor(private direction: 'row' | 'column') {
        super();
    }
    
    doRender() {
        this.$widget = $('<div class="flex-container">')
            .css('display', 'flex')
            .css('flex-direction', this.direction);
            
        for (const child of this.children) {
            this.$widget.append(child.render());
        }
        
        return this.$widget;
    }
}

// Tab container
class TabContainer extends BasicWidget {
    private tabs: Array<{title: string, widget: BasicWidget}> = [];
    
    addTab(title: string, widget: BasicWidget) {
        this.tabs.push({title, widget});
        this.child(widget);
        return this;
    }
    
    doRender() {
        // Render tab headers and content panels
    }
}

Composite Widgets

class NoteEditorWidget extends NoteContextAwareWidget {
    private typeWidget: TypeWidget;
    private titleWidget: NoteTitleWidget;
    private toolbarWidget: NoteToolbarWidget;
    
    constructor() {
        super();
        
        this.child(
            this.toolbarWidget = new NoteToolbarWidget(),
            this.titleWidget = new NoteTitleWidget(),
            // Type widget added dynamically
        );
    }
    
    async refreshWithNote(note: FNote) {
        // Remove old type widget
        if (this.typeWidget) {
            this.typeWidget.remove();
        }
        
        // Add appropriate type widget
        const WidgetClass = typeWidgetService.getWidgetClass(note.type);
        this.typeWidget = new WidgetClass();
        this.child(this.typeWidget);
        
        await this.typeWidget.refresh();
    }
}

Widget Communication

Event System

// Publishing events
class PublisherWidget extends BasicWidget {
    async handleClick() {
        // Local event
        this.trigger('itemSelected', { itemId: '123' });
        
        // Global event
        appContext.triggerEvent('noteChanged', { noteId: this.noteId });
    }
}

// Subscribing to events
class SubscriberWidget extends BasicWidget {
    constructor() {
        super();
        
        // Local event subscription
        this.on('itemSelected', (event) => {
            console.log('Item selected:', event.itemId);
        });
        
        // Global event subscription
        appContext.addEventListener('noteChanged', (event) => {
            this.handleNoteChange(event.noteId);
        });
    }
}

Command System

// Registering commands
class CommandWidget extends BasicWidget {
    constructor() {
        super();
        
        this.bindCommand('saveNote', () => this.saveNote());
        this.bindCommand('deleteNote', () => this.deleteNote());
    }
    
    getCommands() {
        return [
            {
                command: 'myWidget:doAction',
                handler: () => this.doAction(),
                hotkey: 'ctrl+shift+a'
            }
        ];
    }
}

Custom Widget Development

Creating Custom Widgets

// 1. Define widget class
class TaskListWidget extends NoteContextAwareWidget {
    doRender() {
        this.$widget = $('<div class="task-list-widget">');
        this.$list = $('<ul>').appendTo(this.$widget);
        return this.$widget;
    }
    
    async refreshWithNote(note: FNote) {
        const tasks = await this.loadTasks(note);
        
        this.$list.empty();
        for (const task of tasks) {
            $('<li>')
                .text(task.title)
                .toggleClass('completed', task.completed)
                .appendTo(this.$list);
        }
    }
    
    private async loadTasks(note: FNote) {
        // Load task data from note attributes
        const taskLabels = note.getLabels('task');
        return taskLabels.map(label => JSON.parse(label.value));
    }
}

// 2. Register widget
api.addWidget(TaskListWidget);

Widget Lifecycle

class LifecycleWidget extends NoteContextAwareWidget {
    // 1. Construction
    constructor() {
        super();
        console.log('Widget constructed');
    }
    
    // 2. Initial render
    doRender() {
        console.log('Initial render');
        return $('<div>');
    }
    
    // 3. Context initialization
    async refresh() {
        console.log('Context refresh');
        await super.refresh();
    }
    
    // 4. Note updates
    async refreshWithNote(note: FNote) {
        console.log('Note refresh:', note.noteId);
    }
    
    // 5. Cleanup
    async cleanup() {
        console.log('Widget cleanup');
        // Release resources
    }
}

Performance Optimization

Lazy Loading

class LazyWidget extends BasicWidget {
    private contentLoaded = false;
    
    async becomeVisible() {
        if (!this.contentLoaded) {
            await this.loadContent();
            this.contentLoaded = true;
        }
    }
    
    private async loadContent() {
        // Heavy content loading
        const data = await server.get('expensive-data');
        this.renderContent(data);
    }
}

Debouncing Updates

class DebouncedWidget extends NoteContextAwareWidget {
    private refreshDebounced = utils.debounce(
        () => this.doRefresh(),
        500
    );
    
    async refreshWithNote(note: FNote) {
        // Debounce rapid updates
        this.refreshDebounced();
    }
    
    private async doRefresh() {
        // Actual refresh logic
    }
}

Virtual Scrolling

class VirtualListWidget extends BasicWidget {
    private visibleItems: any[] = [];
    
    renderVisibleItems(scrollTop: number) {
        const itemHeight = 30;
        const containerHeight = this.$widget.height();
        
        const startIndex = Math.floor(scrollTop / itemHeight);
        const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
        
        this.visibleItems = this.allItems.slice(startIndex, endIndex);
        this.renderItems();
    }
}

Best Practices

Widget Design

  1. Single Responsibility: Each widget should have one clear purpose
  2. Composition over Inheritance: Use composition for complex UIs
  3. Lazy Initialization: Load resources only when needed
  4. Event Cleanup: Remove event listeners in cleanup()

State Management

class StatefulWidget extends NoteContextAwareWidget {
    private state = {
        isExpanded: false,
        selectedItems: new Set<string>()
    };
    
    setState(updates: Partial<typeof this.state>) {
        Object.assign(this.state, updates);
        this.renderState();
    }
    
    private renderState() {
        this.$widget.toggleClass('expanded', this.state.isExpanded);
        // Update DOM based on state
    }
}

Error Handling

class ResilientWidget extends BasicWidget {
    async refreshWithNote(note: FNote) {
        try {
            await this.loadData(note);
        } catch (error) {
            this.showError('Failed to load data');
            console.error('Widget error:', error);
        }
    }
    
    private showError(message: string) {
        this.$widget.html(`
            <div class="alert alert-danger">
                ${message}
            </div>
        `);
    }
}

Testing Widgets

// Widget test example
describe('TaskListWidget', () => {
    let widget: TaskListWidget;
    let note: FNote;
    
    beforeEach(() => {
        widget = new TaskListWidget();
        note = createMockNote({
            noteId: 'test123',
            attributes: [
                { type: 'label', name: 'task', value: '{"title":"Task 1"}' }
            ]
        });
    });
    
    it('should render tasks', async () => {
        await widget.refreshWithNote(note);
        
        const tasks = widget.$widget.find('li');
        expect(tasks.length).toBe(1);
        expect(tasks.text()).toBe('Task 1');
    });
});