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;
}
Modal Categories
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 |
Modal Patterns
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; }
Modal Lifecycle
// 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(''))
);
}
}