Overview
Modals are dialog windows for user interaction. Inkdown provides several modal types:Modal: Base class for custom dialogsFuzzySuggestModal: Fuzzy search interfaceConfirmModal: Yes/No confirmation dialog
import { Modal } from '@inkdown/api';
class MyModal extends Modal {
onOpen() {
this.setTitle('My Modal');
this.contentEl.setText('Hello!');
}
}
const modal = new MyModal(this.app);
modal.open();
Modal Base Class
Class Definition
abstract class Modal {
app: App;
containerEl: HTMLElement;
contentEl: HTMLElement;
titleEl: HTMLElement;
constructor(app: App);
open(): void;
close(): void;
onOpen(): void;
onClose(): void;
setTitle(title: string): this;
}
Properties
Reference to the application instance.
The modal’s container element.
The modal’s content area. Add your UI elements here.
The modal’s title element.
Methods
setTitle()
Set the modal title.
this.setTitle('My Modal Title');
open()
Show the modal.
const modal = new MyModal(this.app);
modal.open();
close()
Close the modal.
this.close();
onOpen()
Called when the modal opens. Override to build your UI.
onOpen() {
this.setTitle('My Modal');
this.contentEl.createEl('p', { text: 'Content here' });
}
onClose()
Called when the modal closes. Override to clean up.
onClose() {
// Clean up resources
this.cleanup();
}
Creating Custom Modals
Simple Modal
import { Modal } from '@inkdown/api';
class WelcomeModal extends Modal {
onOpen() {
this.setTitle('Welcome!');
this.contentEl.createEl('p', {
text: 'Thanks for using my plugin!',
});
const button = this.contentEl.createEl('button', {
text: 'Close',
});
button.addEventListener('click', () => {
this.close();
});
}
}
// Usage
this.addCommand({
id: 'show-welcome',
name: 'Show Welcome',
callback: () => {
new WelcomeModal(this.app).open();
},
});
Modal with Form
class InputModal extends Modal {
result: string = '';
onSubmit: (result: string) => void;
constructor(app: App, onSubmit: (result: string) => void) {
super(app);
this.onSubmit = onSubmit;
}
onOpen() {
this.setTitle('Enter Text');
const { contentEl } = this;
contentEl.createEl('p', { text: 'Please enter some text:' });
const input = contentEl.createEl('input', {
type: 'text',
placeholder: 'Type here...',
});
input.style.width = '100%';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.submit();
}
});
const buttonContainer = contentEl.createDiv();
buttonContainer.style.marginTop = '10px';
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end';
buttonContainer.style.gap = '8px';
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
const submitBtn = buttonContainer.createEl('button', { text: 'Submit' });
submitBtn.addEventListener('click', () => this.submit());
input.focus();
}
submit() {
const input = this.contentEl.querySelector('input');
if (input) {
this.result = input.value;
this.close();
this.onSubmit(this.result);
}
}
}
// Usage
this.addCommand({
id: 'prompt-input',
name: 'Prompt for Input',
callback: () => {
new InputModal(this.app, (result) => {
this.showNotice(`You entered: ${result}`);
}).open();
},
});
ConfirmModal
Pre-built confirmation dialog.Class Definition
class ConfirmModal extends Modal {
constructor(
app: App,
title: string,
message: string,
confirmText?: string,
cancelText?: string
);
onConfirm(): void;
}
Usage
import { ConfirmModal } from '@inkdown/api';
class DeleteConfirmModal extends ConfirmModal {
file: TFile;
constructor(app: App, file: TFile) {
super(
app,
'Delete File',
`Are you sure you want to delete "${file.name}"?`,
'Delete',
'Cancel'
);
this.file = file;
}
async onConfirm() {
await this.app.workspace.delete(this.file);
new Notice('File deleted');
}
}
// Usage
this.addCommand({
id: 'delete-current-file',
name: 'Delete Current File',
callback: () => {
const file = this.app.workspaceUI.getActiveFile();
if (file) {
new DeleteConfirmModal(this.app, file).open();
}
},
});
FuzzySuggestModal
Fuzzy search modal for selecting items.Class Definition
abstract class FuzzySuggestModal<T> extends Modal {
abstract getItems(): T[];
abstract getItemText(item: T): string;
abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void;
renderSuggestion(match: FuzzyMatch<T>, el: HTMLElement): void;
}
interface FuzzyMatch<T> {
item: T;
match: SearchMatch;
}
interface SearchMatch {
score: number;
matches: Array<[number, number]>;
}
Simple Example
import { FuzzySuggestModal } from '@inkdown/api';
class FileSelectorModal extends FuzzySuggestModal<TFile> {
files: TFile[];
constructor(app: App, files: TFile[]) {
super(app);
this.files = files;
}
getItems(): TFile[] {
return this.files;
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile, evt: MouseEvent | KeyboardEvent) {
this.app.workspaceUI.openFile(file);
}
}
// Usage
this.addCommand({
id: 'select-file',
name: 'Select File',
callback: async () => {
const files = await this.app.workspace.getMarkdownFiles();
new FileSelectorModal(this.app, files).open();
},
});
Custom Rendering
interface CommandItem {
id: string;
name: string;
description: string;
icon: string;
}
class CommandPaletteModal extends FuzzySuggestModal<CommandItem> {
commands: CommandItem[];
constructor(app: App, commands: CommandItem[]) {
super(app);
this.commands = commands;
}
getItems(): CommandItem[] {
return this.commands;
}
getItemText(item: CommandItem): string {
return item.name;
}
renderSuggestion(match: FuzzyMatch<CommandItem>, el: HTMLElement) {
el.style.display = 'flex';
el.style.alignItems = 'center';
el.style.gap = '10px';
// Icon
const icon = el.createDiv({ cls: 'command-icon' });
icon.setText(match.item.icon);
// Content
const content = el.createDiv({ cls: 'command-content' });
content.createDiv({ cls: 'command-name', text: match.item.name });
content.createDiv({
cls: 'command-desc',
text: match.item.description,
});
}
onChooseItem(item: CommandItem, evt: MouseEvent | KeyboardEvent) {
this.app.commandManager.executeCommand(item.id);
}
}
Quick Finder Modal Example
From the built-in Quick Finder plugin:import { SuggestModal, TFile, SearchResult, Notice } from '@inkdown/api';
export class QuickFinderModal extends SuggestModal<TFile | SearchResult> {
private workspacePath = '';
constructor(app: App) {
super(app);
this.setLimit(50);
}
async onOpen() {
await super.onOpen();
const config = await this.app.configManager.loadConfig<{ workspace?: string }>('app');
this.workspacePath = config?.workspace || '';
if (!this.workspacePath) {
this.setTitle('No Workspace');
this.addText('Please open a workspace first.');
return;
}
this.setupShortcuts();
this.createFooter();
}
async getSuggestions(query: string): Promise<(TFile | SearchResult)[]> {
if (!query) {
const allFiles = await this.app.workspace.getMarkdownFiles();
const recentFiles = this.app.workspace.getRecentFiles();
return allFiles
.filter(f => recentFiles.includes(f.path))
.sort((a, b) => recentFiles.indexOf(a.path) - recentFiles.indexOf(b.path));
}
const results = await this.app.searchService.search({
query: query,
searchContent: true,
limit: 50
});
return results;
}
renderSuggestion(item: TFile | SearchResult, el: HTMLElement) {
let file: TFile;
let context: string | undefined;
if ('file' in item) {
const res = item as SearchResult;
file = res.file;
context = res.matchContext;
} else {
file = item as TFile;
}
el.classList.add('quick-finder-item');
const content = document.createElement('div');
content.classList.add('quick-finder-item-content');
el.appendChild(content);
const titleDiv = document.createElement('div');
titleDiv.classList.add('quick-finder-item-title');
titleDiv.textContent = file.path;
content.appendChild(titleDiv);
if (context) {
const ctxDiv = document.createElement('div');
ctxDiv.classList.add('quick-finder-item-context');
ctxDiv.textContent = context;
content.appendChild(ctxDiv);
}
}
async onChooseSuggestion(item: TFile | SearchResult, evt: MouseEvent | KeyboardEvent) {
let file: TFile;
if ('file' in item) {
file = (item as SearchResult).file;
} else {
file = item as TFile;
}
const openInNewTab = (evt.ctrlKey || evt.metaKey);
try {
await this.app.workspaceUI.openFile(file, openInNewTab);
} catch (error) {
console.error('Failed to open note:', error);
new Notice('Failed to open note');
}
}
private setupShortcuts() {
this.scope.register(['Shift'], 'Enter', (e) => {
e.preventDefault();
this.createNote(this.inputValue);
return false;
});
}
private async createNote(inputValue: string) {
const value = inputValue.trim();
if (!value) return;
try {
let notePath: string;
if (value.includes('/')) {
notePath = value.endsWith('.md') ? value : `${value}.md`;
const parentDir = this.app.fileSystemManager.getParentPath(notePath);
if (parentDir) {
await this.app.fileSystemManager.createDirectory(parentDir);
}
} else {
notePath = `${value}.md`;
}
await this.app.fileSystemManager.writeFile(notePath, '');
await this.app.tabManager.openTab(notePath, { openInNewTab: true });
this.close();
} catch (error) {
console.error('Failed to create note:', error);
new Notice('Failed to create note');
}
}
}
Best Practices
Always Close Modals
// ✅ Good
class MyModal extends Modal {
onOpen() {
const button = this.contentEl.createEl('button', { text: 'Close' });
button.addEventListener('click', () => this.close());
}
}
// ❌ Bad - no way to close
class MyModal extends Modal {
onOpen() {
this.contentEl.createEl('p', { text: 'Content' });
// User can't close the modal!
}
}
Clean Up Resources
class MyModal extends Modal {
private interval: number | null = null;
onOpen() {
this.interval = window.setInterval(() => {
this.update();
}, 1000);
}
onClose() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
Handle Keyboard Events
class MyModal extends Modal {
onOpen() {
const input = this.contentEl.createEl('input');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.submit();
} else if (e.key === 'Escape') {
this.close();
}
});
}
}
Provide Feedback
class ProcessModal extends ConfirmModal {
async onConfirm() {
new Notice('Processing...');
try {
await this.processFiles();
new Notice('Processing complete!');
} catch (error) {
new Notice('Processing failed');
}
}
}
