diff --git a/docs/documentation.toml b/docs/documentation.toml index 9eebfdbd..801c9e4f 100644 --- a/docs/documentation.toml +++ b/docs/documentation.toml @@ -287,6 +287,35 @@ description = "Throws an error if the prompt is canceled, instead of returning a name = "multiline" description = "If set to true, the input field will be a multiline textarea" +[tp.system.functions.multi_suggester] +name = "multi_suggester" +description = "Spawns a multi_suggester prompt and returns the user's chosen items." +definition = "tp.system.multi_suggester(text_items: string[] ⎮ ((item: T) => string), items: T[], unrestricted: boolean = false, throw_on_cancel: boolean = false, title: string = \"multi_suggester\", limit?: number = undefined)" + +[tp.system.functions.multi_suggester.args.text_items] +name = "text_items" +description = "Array of strings representing the text that will be displayed for each item in the multi_suggester prompt. This can also be a function that maps an item to its text representation." + +[tp.system.functions.multi_suggester.args.items] +name = "items" +description = "Array containing the values of each item in the correct order." + +[tp.system.functions.multi_suggester.args.unrestricted] +name = "unrestricted" +description = "A boolean which represents whether the values outside suggestions can be selected or not" + +[tp.system.functions.multi_suggester.args.throw_on_cancel] +name = "throw_on_cancel" +description = "Throws an error if the prompt is canceled, instead of returning a `null` value" + +[tp.system.functions.multi_suggester.args.title] +name = "title" +description = "Title of multi_suggester prompt" + +[tp.system.functions.multi_suggester.args.limit] +name = "limit" +description = "Limit the number of items rendered at once (useful to improve performance when displaying large lists)" + [tp.system.functions.suggester] name = "suggester" description = "Spawns a suggester prompt and returns the user's chosen item." diff --git a/src/core/functions/internal_functions/system/InternalModuleSystem.ts b/src/core/functions/internal_functions/system/InternalModuleSystem.ts index a0403616..f57f6168 100644 --- a/src/core/functions/internal_functions/system/InternalModuleSystem.ts +++ b/src/core/functions/internal_functions/system/InternalModuleSystem.ts @@ -1,8 +1,7 @@ -import { UNSUPPORTED_MOBILE_TEMPLATE } from "utils/Constants"; import { InternalModule } from "../InternalModule"; -import { Platform } from "obsidian"; import { PromptModal } from "./PromptModal"; import { SuggesterModal } from "./SuggesterModal"; +import { MultiSuggesterModal } from "./MultiSuggesterModal"; import { TemplaterError } from "utils/Error"; import { ModuleName } from "editor/TpDocumentation"; @@ -13,16 +12,14 @@ export class InternalModuleSystem extends InternalModule { this.static_functions.set("clipboard", this.generate_clipboard()); this.static_functions.set("prompt", this.generate_prompt()); this.static_functions.set("suggester", this.generate_suggester()); + this.static_functions.set("multi_suggester", this.generate_multi_suggester()); } - async create_dynamic_templates(): Promise {} + async create_dynamic_templates(): Promise { + } generate_clipboard(): () => Promise { return async () => { - // TODO: Add mobile support - if (Platform.isMobileApp) { - return UNSUPPORTED_MOBILE_TEMPLATE; - } return await navigator.clipboard.readText(); }; } @@ -61,6 +58,46 @@ export class InternalModuleSystem extends InternalModule { }; } + generate_multi_suggester(): ( + text_items: string[] | ((item: T) => string), + items: T[], + unrestricted: boolean, + throw_on_cancel: boolean, + title: string, + limit?: number + ) => Promise { + return async ( + text_items: string[] | ((item: T) => string), + items: T[], + unrestricted = false, + throw_on_cancel = false, + title = "Multi Suggester", + limit?: number + ): Promise => { + const multi_suggester = new MultiSuggesterModal( + text_items, + items, + unrestricted, + title, + limit + ); + const promise = new Promise( + ( + resolve: (values: T[]) => void, + reject: (reason?: TemplaterError) => void + ) => multi_suggester.openAndGetValues(resolve, reject) + ); + try { + return await promise; + } catch (error) { + if (throw_on_cancel) { + throw error; + } + return []; + } + }; + } + generate_suggester(): ( text_items: string[] | ((item: T) => string), items: T[], diff --git a/src/core/functions/internal_functions/system/MultiSuggesterModal.ts b/src/core/functions/internal_functions/system/MultiSuggesterModal.ts new file mode 100644 index 00000000..6f03674a --- /dev/null +++ b/src/core/functions/internal_functions/system/MultiSuggesterModal.ts @@ -0,0 +1,229 @@ +import {Modal, TextComponent, fuzzySearch, prepareQuery} from "obsidian"; +import {TemplaterError} from "utils/Error"; + +export class MultiSuggesterModal extends Modal { + private resolve: (values: T[]) => void; + private reject: (reason?: TemplaterError) => void; + private selectedItems = new Set(); + private mappedItems = new Map(); + private submitted = false; + + private SELECTOR_SELECTED = ".is-selected"; + private CLASS_SELECTED = "is-selected"; + + private suggestionsContainer: HTMLDivElement; + private chipsContainer: HTMLDivElement; + private textInput: TextComponent; + + constructor( + private text_items: string[] | ((item: T) => string), + private items: T[], + private unrestricted: boolean, + title: string, + private limit?: number + ) { + super(app); + this.initializeModal(items, title); + } + + initializeModal(items: T[], title: string) { + // If item isn't of type string or items array is empty, then unrestricted is false + if (items.length === 0 || typeof items[0] !== "string") { + this.unrestricted = false; + } + + // Map each item to its text representation + items.forEach((item) => + this.mappedItems.set(this.getItemText(item), item) + ); + + // Create Elements + this.titleEl.setText(title); + this.chipsContainer = this.contentEl.createDiv(); + this.textInput = new TextComponent(this.contentEl); + this.suggestionsContainer = this.contentEl.createDiv(); + + // Add styles + this.modalEl.addClass("templater-multiselect-modal"); + this.contentEl.addClass("templater-multiselect-container"); + this.chipsContainer.addClass("templater-multiselect-chips"); + this.suggestionsContainer.addClass("templater-multiselect-suggestions"); + } + + onOpen(): void { + this.textInput.inputEl.addEventListener( + "keydown", + (evt: KeyboardEvent) => { + switch (evt.key) { + case "Tab": + this.addItemFromSuggestionsOrInputField(); + break; + + case "Backspace": + if (!this.textInput.getValue()) this.removeLastItem(); + break; + + case "ArrowDown": + this.navigateSelection("DOWN"); + break; + + case "ArrowUp": + this.navigateSelection("UP"); + break; + + case "Enter": + this.submitted = true; + this.close(); + break; + } + } + ); + + this.textInput.onChange(() => this.renderSuggestions()); + this.renderSuggestions(); + } + + addItemFromSuggestionsOrInputField() { + const inputText = this.textInput.getValue().trim(); + const selected = this.suggestionsContainer.querySelector( + this.SELECTOR_SELECTED + ); + if (selected) this.addItem(selected.textContent!); + else if (inputText && this.unrestricted) this.addItem(inputText); + } + + addItem(value: string) { + this.selectedItems.add(value); + this.addChip(value); + this.textInput.setValue(""); + this.renderSuggestions(); + } + + removeLastItem() { + const lastChipValue = this.chipsContainer.lastElementChild?.textContent; + if (!lastChipValue) return; + this.selectedItems.delete(lastChipValue); + this.removeLastChip(); + this.renderSuggestions(); + } + + addChip(value: string) { + const chip = this.chipsContainer.createSpan({text: value}); + chip.addClass("templater-multiselect-chip"); + chip.onClickEvent(() => { + this.selectedItems.delete(chip.textContent!); + this.renderSuggestions(); + chip.remove(); + }); + } + + removeLastChip() { + const lastChip = this.chipsContainer.lastElementChild; + lastChip?.remove(); + } + + matchText(text: string): boolean { + const query = this.textInput.getValue(); + const preparedQuery = prepareQuery(query); + const match = fuzzySearch(preparedQuery, text); + const alreadySelected = this.selectedItems.has(text); + if (match && !alreadySelected) return true; + else return false; + } + + matchItem(item: T): boolean { + const text = this.getItemText(item); + return this.matchText(text); + } + + getItemText(item: T): string { + if (this.text_items instanceof Function) { + return this.text_items(item); + } + return ( + this.text_items[this.items.indexOf(item)] || "Undefined Text Item" + ); + } + + getSuggestions(): string[] { + return this.items + .map((item) => this.getItemText(item)) + .filter((value) => this.matchText(value)) + .slice(0, this.limit ? this.limit : this.items.length); + } + + renderSuggestion(value: string) { + const suggestion = this.suggestionsContainer.createDiv({text: value}); + suggestion.addClass("suggestion-item"); + suggestion.onClickEvent(() => { + this.addChip(value); + this.selectedItems.add(value); + this.renderSuggestions(); + }); + + suggestion.addEventListener("mouseenter", () => { + const selectedSuggestions = + this.suggestionsContainer.querySelectorAll(".is-selected"); + selectedSuggestions.forEach((suggestion) => + suggestion.removeClass(this.CLASS_SELECTED) + ); + suggestion.addClass(this.CLASS_SELECTED); + }); + } + + renderSuggestions() { + this.suggestionsContainer.empty(); + const suggestions = this.getSuggestions(); + suggestions.forEach((text) => this.renderSuggestion(text)); + this.suggestionsContainer.firstElementChild?.addClass( + this.CLASS_SELECTED + ); + } + + navigateSelection(navigate: "UP" | "DOWN") { + if (this.suggestionsContainer.children.length <= 1) return; + const selected = this.suggestionsContainer.querySelector( + this.SELECTOR_SELECTED + ); + + const nextElement = selected?.nextElementSibling; + const previousElement = selected?.previousElementSibling; + + const firstSuggestion = this.suggestionsContainer.firstElementChild; + const lastSuggestion = this.suggestionsContainer.lastElementChild; + + let nextSuggestion = + navigate === "DOWN" ? nextElement : previousElement; + if (!nextSuggestion) { + nextSuggestion = + navigate === "DOWN" ? firstSuggestion : lastSuggestion; + } + + selected?.removeClass(this.CLASS_SELECTED); + nextSuggestion?.addClass(this.CLASS_SELECTED); + nextSuggestion?.scrollIntoView(); + } + + onClose(): void { + if (!this.submitted) { + this.reject(new TemplaterError("Cancelled prompt")); + } else { + const values: T[] = []; + this.selectedItems.forEach((value) => { + const item = this.mappedItems.get(value); + if (item) values.push(item); + else if (this.unrestricted) values.push(value as T); + }); + this.resolve(values); + } + } + + async openAndGetValues( + resolve: (values: T[]) => void, + reject: (reason?: TemplaterError) => void + ): Promise { + this.resolve = resolve; + this.reject = reject; + this.open(); + } +} diff --git a/styles.css b/styles.css index 54ecbccb..c8e3b061 100644 --- a/styles.css +++ b/styles.css @@ -54,6 +54,49 @@ justify-content: center; } +.templater-multiselect-modal { + position: absolute; + top: 80px; +} + +.templater-multiselect-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.templater-multiselect-chips { + display: flex; + flex-wrap: wrap; + overflow-y: auto; + max-height: 106px; + gap: 8px; +} + +.templater-multiselect-chip { + border: var(--border-width) solid var(--color-green); + border-radius: var(--radius-s); + text-overflow: ellipsis; + display: inline-block; + white-space: nowrap; + overflow: hidden; + padding: 4px 8px; + cursor: pointer; + height: 30px; +} + +.templater-multiselect-chip:hover { + border: var(--border-width) solid var(--color-red); +} + +.templater-multiselect-suggestions { + overflow-y: auto; + max-height: 324px; + box-sizing: border-box; + padding: 0; + margin: 0; +} + .templater-prompt-div { display: flex; }