Storyteller Suite - Modals System

Overview

The plugin uses modal dialogs for all entity CRUD operations. All modals extend ResponsiveModal for consistent mobile/desktop support.

Base Class: ResponsiveModal

// ResponsiveModal.ts
abstract class ResponsiveModal extends Modal {
    protected isMobile: boolean;
    
    constructor(app: App) {
        super(app);
        this.isMobile = Platform.isMobile || Platform.isTablet;
        
        if (this.isMobile) {
            this.modalEl.addClass('storyteller-modal-mobile');
        }
    }
    
    // Common UI patterns
    protected addTextField(container: HTMLElement, opts: TextFieldOptions): Setting;
    protected addTextArea(container: HTMLElement, opts: TextAreaOptions): Setting;
    protected addDropdown(container: HTMLElement, opts: DropdownOptions): Setting;
    protected addButton(container: HTMLElement, opts: ButtonOptions): ButtonComponent;
}

Entity Modals (Create/Edit)

Modal Entity File
CharacterModal Character CharacterModal.ts
LocationModal Location LocationModal.ts
EventModal Event EventModal.ts
SceneModal Scene SceneModal.ts
ChapterModal Chapter ChapterModal.ts
GroupModal Group GroupModal.ts
MapModal Map MapModal.ts
PlotItemModal Item PlotItemModal.ts
ReferenceModal Reference ReferenceModal.ts

List Modals (View All)

Modal Purpose File
CharacterListModal View all characters CharacterListModal.ts
LocationListModal View all locations LocationListModal.ts
ConflictListModal View conflicts ConflictListModal.ts
CultureListModal View cultures CultureListModal.ts
EconomyListModal View economies EconomyListModal.ts
EraListModal View eras EraListModal.ts
MagicSystemListModal View magic systems MagicSystemListModal.ts
MapListModal View all maps MapListModal.ts
PlotItemListModal View all items PlotItemListModal.ts
TrackListModal View timeline tracks TrackListModal.ts

Suggest Modals (Selection)

Modal Purpose File
CharacterSuggestModal Select a character CharacterSuggestModal.ts
LocationSuggestModal Select a location LocationSuggestModal.ts
EventSuggestModal Select an event EventSuggestModal.ts
GroupSuggestModal Select a group GroupSuggestModal.ts
PlotItemSuggestModal Select an item PlotItemSuggestModal.ts
FolderSuggestModal Select a folder FolderSuggestModal.ts
GalleryImageSuggestModal Select an image GalleryImageSuggestModal.ts

Specialized Modals

Modal Purpose File
DashboardModal Main dashboard DashboardModal.ts
TimelineModal Timeline viewer TimelineModal.ts
NetworkGraphModal Relationship graph NetworkGraphModal.ts
GalleryModal Image gallery GalleryModal.ts
ImportConfigModal Import wizard ImportConfigModal.ts
TemplateLibraryModal Template browser TemplateLibraryModal.ts
TemplatePickerModal Template selection TemplatePickerModal.ts
RelationshipEditorModal Edit relationships RelationshipEditorModal.ts
CausalityLinkModal Event dependencies CausalityLinkModal.ts
SensoryProfileModal Location sensory details SensoryProfileModal.ts
TagTimelineModal Tag-based timeline TagTimelineModal.ts
TimelineForkModal Alternate timelines TimelineForkModal.ts

UI Utility Modals

Modal Purpose File
ConfirmModal Confirmation dialog ui/ConfirmModal.ts
PromptModal Text input prompt ui/PromptModal.ts
ImageDetailModal Image viewer ImageDetailModal.ts
AddEntityToLocationModal Add marker to map AddEntityToLocationModal.ts

Entity Create/Edit Pattern

class CharacterModal extends ResponsiveModal {
    private character: Character;
    private isEdit: boolean;
    private onSave: (character: Character) => void;
    
    constructor(
        app: App, 
        plugin: StorytellerSuitePlugin,
        character?: Character,
        onSave?: (character: Character) => void
    ) {
        super(app);
        this.isEdit = !!character;
        this.character = character || this.createBlank();
        this.onSave = onSave;
    }
    
    onOpen(): void {
        const { contentEl } = this;
        contentEl.empty();
        contentEl.addClass('storyteller-modal');
        
        // Title
        contentEl.createEl('h2', { 
            text: this.isEdit ? t('editCharacter') : t('createNewCharacter') 
        });
        
        // Form fields
        this.renderForm(contentEl);
        
        // Buttons
        this.renderButtons(contentEl);
    }
    
    private renderForm(container: HTMLElement): void {
        // Name field (required)
        new Setting(container)
            .setName(t('name'))
            .addText(text => text
                .setValue(this.character.name)
                .onChange(value => this.character.name = value)
            );
        
        // Description
        new Setting(container)
            .setName(t('description'))
            .addTextArea(textarea => textarea
                .setValue(this.character.description || '')
                .onChange(value => this.character.description = value)
            );
        
        // ... more fields
    }
    
    private renderButtons(container: HTMLElement): void {
        const buttonRow = container.createDiv('storyteller-button-row');
        
        // Save button
        new ButtonComponent(buttonRow)
            .setButtonText(this.isEdit ? t('saveChanges') : t('createCharacter'))
            .setCta()
            .onClick(async () => {
                if (!this.validate()) return;
                await this.save();
                this.close();
            });
        
        // Cancel button
        new ButtonComponent(buttonRow)
            .setButtonText(t('cancel'))
            .onClick(() => this.close());
        
        // Delete button (edit mode only)
        if (this.isEdit) {
            new ButtonComponent(buttonRow)
                .setButtonText(t('deleteCharacter'))
                .setWarning()
                .onClick(() => this.confirmDelete());
        }
    }
}

Suggest Modal Pattern

class CharacterSuggestModal extends FuzzySuggestModal<Character> {
    private characters: Character[];
    private onSelect: (character: Character) => void;
    
    constructor(
        app: App,
        plugin: StorytellerSuitePlugin,
        onSelect: (character: Character) => void
    ) {
        super(app);
        this.onSelect = onSelect;
        this.loadCharacters();
    }
    
    getItems(): Character[] {
        return this.characters;
    }
    
    getItemText(character: Character): string {
        return character.name;
    }
    
    onChooseItem(character: Character, evt: MouseEvent | KeyboardEvent): void {
        this.onSelect(character);
    }
    
    // Optional: render custom item
    renderSuggestion(character: FuzzyMatch<Character>, el: HTMLElement): void {
        el.createEl('div', { 
            text: character.item.name,
            cls: 'suggestion-title' 
        });
        
        if (character.item.description) {
            el.createEl('small', { 
                text: character.item.description,
                cls: 'suggestion-desc' 
            });
        }
    }
}

List Modal Pattern

class CharacterListModal extends ResponsiveModal {
    private characters: Character[] = [];
    private filter: string = '';
    
    async onOpen(): Promise<void> {
        this.characters = await this.plugin.listCharacters();
        this.render();
    }
    
    private render(): void {
        const { contentEl } = this;
        contentEl.empty();
        
        // Search bar
        const searchContainer = contentEl.createDiv('search-container');
        new TextComponent(searchContainer)
            .setPlaceholder(t('searchX', t('characters')))
            .onChange(value => {
                this.filter = value;
                this.renderList(listContainer);
            });
        
        // Create button
        new ButtonComponent(searchContainer)
            .setButtonText(t('createCharacter'))
            .onClick(() => {
                new CharacterModal(this.app, this.plugin, undefined, 
                    () => this.refresh()
                ).open();
            });
        
        // List container
        const listContainer = contentEl.createDiv('list-container');
        this.renderList(listContainer);
    }
    
    private renderList(container: HTMLElement): void {
        container.empty();
        
        const filtered = this.characters.filter(c => 
            c.name.toLowerCase().includes(this.filter.toLowerCase())
        );
        
        for (const character of filtered) {
            const item = container.createDiv('list-item');
            
            // Thumbnail
            if (character.image) {
                item.createEl('img', { 
                    attr: { src: this.plugin.getImagePath(character.image) } 
                });
            }
            
            // Name and description
            const info = item.createDiv('item-info');
            info.createEl('strong', { text: character.name });
            if (character.description) {
                info.createEl('p', { text: character.description });
            }
            
            // Actions
            const actions = item.createDiv('item-actions');
            new ButtonComponent(actions)
                .setIcon('edit')
                .onClick(() => this.editCharacter(character));
            new ButtonComponent(actions)
                .setIcon('trash')
                .onClick(() => this.deleteCharacter(character));
        }
    }
}

Dashboard Modal

The main entry point (DashboardModal.ts):

class DashboardModal extends ResponsiveModal {
    private activeTab: EntityType = 'character';
    
    onOpen(): void {
        this.renderTabs();
        this.renderContent();
    }
    
    private renderTabs(): void {
        const tabs = [
            { id: 'character', icon: 'user', label: t('characters') },
            { id: 'location', icon: 'map-pin', label: t('locations') },
            { id: 'event', icon: 'calendar', label: t('events') },
            { id: 'scene', icon: 'file-text', label: t('scenes') },
            { id: 'chapter', icon: 'book', label: t('chapters') },
            { id: 'item', icon: 'package', label: t('items') },
            { id: 'group', icon: 'users', label: t('groups') },
            { id: 'gallery', icon: 'image', label: t('gallery') },
            { id: 'timeline', icon: 'activity', label: t('timeline') },
        ];
        
        // Render tab bar
        // Handle tab visibility settings
    }
    
    private renderContent(): void {
        switch (this.activeTab) {
            case 'character':
                this.renderCharacterList();
                break;
            case 'timeline':
                this.renderTimeline();
                break;
            // ... etc
        }
    }
}

Styling

Modal CSS classes:

/* Base modal */
.storyteller-modal { ... }
.storyteller-modal-mobile { ... }

/* Form elements */
.storyteller-modal-input-large { width: 100%; }
.storyteller-modal-textarea { min-height: 100px; }
.storyteller-modal-setting-vertical { flex-direction: column; }

/* Buttons */
.storyteller-button-row { display: flex; gap: 8px; }

/* Lists */
.storyteller-list-item { display: flex; align-items: center; }
.storyteller-list-item-thumbnail { width: 48px; height: 48px; }
// Opening a modal
const modal = new CharacterModal(this.app, this.plugin);
modal.open();

// Modal methods
onOpen(): void    // Called when modal opens
onClose(): void   // Called when modal closes

// Closing a modal
modal.close();    // Programmatic close
// or Escape key / click outside

Internationalization

All modal text uses the t() function:

import { t } from '../i18n/strings';

// Usage
container.createEl('h2', { text: t('createNewCharacter') });

// With parameters
new Notice(t('created', t('character'), character.name));
// → "Character "Sir Aldric" created."

Template Integration

Entity modals support templates:

// In CharacterModal
if (hasTemplates) {
    new Setting(contentEl)
        .setName(t('applyTemplate'))
        .addButton(button => button
            .setButtonText(t('selectTemplate'))
            .onClick(() => {
                new TemplatePickerModal(this.app, this.plugin, 'character',
                    (template) => this.applyTemplate(template)
                ).open();
            })
        );
}

Image Selection

Image picker pattern used across modals:

// ImageSelectionHelper.ts
function addImageSelectionButtons(
    setting: Setting,
    app: App,
    plugin: StorytellerSuitePlugin,
    options: {
        currentPath?: string;
        onSelect: (path: string) => void;
        descriptionEl?: HTMLElement;
    }
): void {
    // Gallery button
    setting.addButton(button => button
        .setIcon('image')
        .setTooltip(t('selectFromGallery'))
        .onClick(() => {
            new GalleryImageSuggestModal(app, plugin, (imagePath) => {
                options.onSelect(imagePath);
            }).open();
        })
    );
    
    // Upload button
    setting.addButton(button => button
        .setIcon('upload')
        .setTooltip(t('uploadImage'))
        .onClick(() => this.uploadImage())
    );
    
    // Clear button
    if (options.currentPath) {
        setting.addButton(button => button
            .setIcon('x')
            .setTooltip(t('clearImage'))
            .onClick(() => options.onSelect(''))
        );
    }
}