diff --git a/@types/index.ts b/@types/index.ts index f0c51ab6..f0c1bf68 100644 --- a/@types/index.ts +++ b/@types/index.ts @@ -7,7 +7,7 @@ export declare abstract class StatblockMonsterPlugin extends Plugin { el: HTMLElement, ctx: MarkdownPostProcessorContext ): Promise; - get sorted(): string[]; + get sorted(): Monster[]; abstract saveMonster(monster: Monster): Promise; abstract saveMonsters(monsters: Monster[]): Promise; abstract deleteMonster(monster: string): Promise; diff --git a/src/main.ts b/src/main.ts index 68812088..56fac3e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,10 +24,12 @@ export default class StatBlockPlugin { data: Map; bestiary: Map; - private _sorted: string[] = []; + private _sorted: Monster[] = []; get sorted() { if (!this._sorted.length) - this._sorted = sort(Array.from(this.data.keys())).asc(); + this._sorted = sort(Array.from(this.data.values())).asc( + (m) => m.name + ); return this._sorted; } async onload() { @@ -65,14 +67,18 @@ export default class StatBlockPlugin this.bestiary.set(monster.name, monster); await this.saveData(this._transformData(this.data)); if (sortFields) - this._sorted = sort(Array.from(this.data.keys())).asc(); + this._sorted = sort( + Array.from(this.data.values()) + ).asc((m) => m.name); } } async saveMonsters(monsters: Monster[]) { for (let monster of monsters) { await this.saveMonster(monster, false); } - this._sorted = sort(Array.from(this.data.keys())).asc(); + this._sorted = sort(Array.from(this.data.values())).asc( + (m) => m.name + ); } async deleteMonster(monster: string) { @@ -80,7 +86,9 @@ export default class StatBlockPlugin this.data.delete(monster); this.bestiary.delete(monster); await this.saveData(this._transformData(this.data)); - this._sorted = sort(Array.from(this.data.keys())).asc(); + this._sorted = sort(Array.from(this.data.values())).asc( + (m) => m.name + ); } private _transformData(data: Map): any[] { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 5e23fd79..88c4568b 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,6 +1,13 @@ import { StatblockMonsterPlugin } from "@types"; -import { App, Notice, PluginSettingTab, Setting } from "obsidian"; +import { + App, + Notice, + PluginSettingTab, + Setting, + TextComponent +} from "obsidian"; import { DnDAppFilesImporter } from "src/importers/DnDAppFilesImporter"; +import { MonsterSuggester } from "src/util/suggester"; export default class StatblockSettingTab extends PluginSettingTab { constructor(app: App, private plugin: StatblockMonsterPlugin) { @@ -65,9 +72,13 @@ export default class StatblockSettingTab extends PluginSettingTab { const additionalContainer = containerEl.createDiv( "statblock-additional-container" ); - new Setting(additionalContainer) + let monsterFilter: TextComponent; + const homebrewMonsters = new Setting(additionalContainer) .setName("Homebrew Monsters") - .setDesc("Manage saved homebrew monsters."); + .addText((t) => { + t.setPlaceholder("Filter Monsters"); + monsterFilter = t; + }); const additional = additionalContainer.createDiv("additional"); if (!this.plugin.data.size) { additional @@ -81,8 +92,38 @@ export default class StatblockSettingTab extends PluginSettingTab { }); return; } - //TODO: MEMOIZE THE SORT - for (let monster of this.plugin.sorted) { + + let suggester = new MonsterSuggester( + this.plugin, + monsterFilter, + additional, + this.plugin.sorted + ); + + homebrewMonsters.setDesc( + `Manage homebrew monsters. Currently: ${ + suggester.getItems().length + } monsters.` + ); + + suggester.onRemoveItem = async (monster) => { + try { + await this.plugin.deleteMonster(monster.name); + } catch (e) { + new Notice( + `There was an error deleting the monster:${ + `\n\n` + e.message + }` + ); + } + this.display(); + }; + suggester.onInputChanged = () => + homebrewMonsters.setDesc( + `Manage homebrew monsters. Currently: ${suggester.filteredItems.length} monsters.` + ); + + /* for (let monster of this.plugin.sorted) { let setting = new Setting(additional) .setName(monster) .setDesc(this.plugin.data.get(monster).source ?? ""); @@ -103,7 +144,7 @@ export default class StatblockSettingTab extends PluginSettingTab { this.display(); }); }); - } + } */ } catch (e) { new Notice( "There was an error displaying the settings tab for 5e Statblocks." diff --git a/src/util/suggester.ts b/src/util/suggester.ts new file mode 100644 index 00000000..679082ce --- /dev/null +++ b/src/util/suggester.ts @@ -0,0 +1,279 @@ +import { Monster, StatblockMonsterPlugin } from "@types"; +import { + App, + FuzzyMatch, + FuzzySuggestModal, + Notice, + Scope, + Setting, + SuggestModal, + TextComponent +} from "obsidian"; +/* import { createPopper, Instance as PopperInstance } from "@popperjs/core"; */ + +class Suggester { + owner: SuggestModal; + items: T[]; + suggestions: HTMLDivElement[]; + selectedItem: number; + containerEl: HTMLElement; + constructor( + owner: SuggestModal, + containerEl: HTMLElement, + scope: Scope + ) { + this.containerEl = containerEl; + this.owner = owner; + /* containerEl.on( + "click", + ".suggestion-item", + this.onSuggestionClick.bind(this) + ); */ + /* containerEl.on( + "mousemove", + ".suggestion-item", + this.onSuggestionMouseover.bind(this) + ); */ + + scope.register([], "ArrowUp", () => { + this.setSelectedItem(this.selectedItem - 1, true); + return false; + }); + + scope.register([], "ArrowDown", () => { + this.setSelectedItem(this.selectedItem + 1, true); + return false; + }); + + scope.register([], "Enter", (evt) => { + this.useSelectedItem(evt); + return false; + }); + + scope.register([], "Tab", (evt) => { + this.chooseSuggestion(evt); + return false; + }); + } + chooseSuggestion(evt: KeyboardEvent) { + if (!this.items || !this.items.length) return; + const currentValue = this.items[this.selectedItem]; + if (currentValue) { + this.owner.onChooseSuggestion(currentValue, evt); + } + } + onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { + event.preventDefault(); + if (!this.suggestions || !this.suggestions.length) return; + + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + this.useSelectedItem(event); + } + + onSuggestionMouseover(event: MouseEvent, el: HTMLDivElement): void { + if (!this.suggestions || !this.suggestions.length) return; + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + } + empty() { + this.containerEl.empty(); + } + setSuggestions(items: T[]) { + this.containerEl.empty(); + const els: HTMLDivElement[] = []; + + items.forEach((item) => { + const suggestionEl = + this.containerEl.createDiv(/* "suggestion-item" */); + this.owner.renderSuggestion(item, suggestionEl); + els.push(suggestionEl); + }); + this.items = items; + this.suggestions = els; + this.setSelectedItem(0, false); + } + useSelectedItem(event: MouseEvent | KeyboardEvent) { + if (!this.items || !this.items.length) return; + const currentValue = this.items[this.selectedItem]; + if (currentValue) { + this.owner.selectSuggestion(currentValue, event); + } + } + wrap(value: number, size: number): number { + return ((value % size) + size) % size; + } + setSelectedItem(index: number, scroll: boolean) { + const nIndex = this.wrap(index, this.suggestions.length); + const prev = this.suggestions[this.selectedItem]; + const next = this.suggestions[nIndex]; + + /* if (prev) prev.removeClass("is-selected"); + if (next) next.addClass("is-selected"); */ + + this.selectedItem = nIndex; + + if (scroll) { + next.scrollIntoView(false); + } + } +} + +abstract class SuggestionModal extends FuzzySuggestModal { + items: T[] = []; + suggestions: HTMLDivElement[]; + scope: Scope = new Scope(); + suggester: Suggester>; + suggestEl: HTMLDivElement; + promptEl: HTMLDivElement; + emptyStateText: string = "No match found"; + limit: number = 100; + filteredItems: FuzzyMatch[] = []; + constructor( + app: App, + inputEl: HTMLInputElement, + suggestEl: HTMLDivElement, + items: T[] + ) { + super(app); + this.inputEl = inputEl; + this.items = items; + + this.suggestEl = suggestEl.createDiv(/* "suggestion-container" */); + + this.contentEl = this.suggestEl.createDiv(/* "suggestion" */); + + this.suggester = new Suggester(this, this.contentEl, this.scope); + + this.scope.register([], "Escape", this.close.bind(this)); + + this.inputEl.addEventListener("input", this._onInputChanged.bind(this)); + this.inputEl.addEventListener("focus", this._onInputChanged.bind(this)); + this.inputEl.addEventListener("blur", this.close.bind(this)); + this.suggestEl.on( + "mousedown", + ".suggestion-container", + (event: MouseEvent) => { + event.preventDefault(); + } + ); + } + empty() { + this.suggester.empty(); + } + _onInputChanged(): void { + const inputStr = this.inputEl.value; + this.filteredItems = this.getSuggestions(inputStr); + if (this.filteredItems.length > 0) { + this.suggester.setSuggestions( + this.filteredItems.slice(0, this.limit) + ); + } else { + this.onNoSuggestion(); + } + this.onInputChanged(); + this.open(); + } + onInputChanged(): void {} + onNoSuggestion() { + this.empty(); + this.renderSuggestion( + null, + this.contentEl.createDiv(/* "suggestion-item" */) + ); + } + open(): void {} + + close(): void { + this.suggester.setSuggestions([]); + } + createPrompt(prompts: HTMLSpanElement[]) { + if (!this.promptEl) + this.promptEl = this.suggestEl.createDiv("prompt-instructions"); + let prompt = this.promptEl.createDiv("prompt-instruction"); + for (let p of prompts) { + prompt.appendChild(p); + } + } + abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void; + abstract getItemText(arg: T): string; + abstract getItems(): T[]; +} + +export class MonsterSuggester extends SuggestionModal { + monsters: Monster[]; + monster: Monster; + text: TextComponent; + constructor( + public plugin: StatblockMonsterPlugin, + input: TextComponent, + el: HTMLDivElement, + items: Monster[] + ) { + super(plugin.app, input.inputEl, el, items); + this.monsters = [...items]; + this.text = input; + //this.getItem(); + this._onInputChanged(); + this.createPrompts(); + + this.inputEl.addEventListener("input", this.getItem.bind(this)); + } + createPrompts() {} + getItem() { + const v = this.inputEl.value, + monster = this.monsters.find( + (c) => c.name === v.trim() /* || c.source === v.trim() */ + ); + if (monster == this.monster) return; + this.monster = monster; + if (this.monster) + /* this.cache = this.app.metadataCache.getFileCache(this.command); */ + this._onInputChanged(); + } + getItemText(item: Monster) { + return item.name /* + item.source */; + } + onChooseItem() {} + selectSuggestion() {} + renderSuggestion(result: FuzzyMatch, el: HTMLElement) { + let { item, match: matches } = result || {}; + let content = new Setting(el); /* el.createDiv({ + cls: "suggestion-content" + }); */ + if (!item) { + content.nameEl.setText(this.emptyStateText); + /* content.parentElement.addClass("is-selected"); */ + return; + } + + const matchElements = matches.matches.map((m) => { + return createSpan("suggestion-highlight"); + }); + for (let i = 0; i < item.name.length; i++) { + let match = matches.matches.find((m) => m[0] === i); + if (match) { + let element = matchElements[matches.matches.indexOf(match)]; + content.nameEl.appendChild(element); + element.appendText(item.name.substring(match[0], match[1])); + + i += match[1] - match[0] - 1; + continue; + } + + content.nameEl.appendText(item.name[i]); + } + + content.setDesc(item.source); + content.addExtraButton((b) => { + b.setIcon("trash") + .setTooltip("Delete") + .onClick(() => this.onRemoveItem(item)); + }); + } + getItems() { + return this.monsters; + } + onClose(item?: Monster) {} + onRemoveItem(item: Monster) {} +}