Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multiselect function to system module #1149

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/documentation.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<void> {}
async create_dynamic_templates(): Promise<void> {
}

generate_clipboard(): () => Promise<string | null> {
return async () => {
// TODO: Add mobile support
if (Platform.isMobileApp) {
return UNSUPPORTED_MOBILE_TEMPLATE;
}
return await navigator.clipboard.readText();
};
}
Expand Down Expand Up @@ -61,6 +58,46 @@ export class InternalModuleSystem extends InternalModule {
};
}

generate_multi_suggester(): <T>(
text_items: string[] | ((item: T) => string),
items: T[],
unrestricted: boolean,
throw_on_cancel: boolean,
title: string,
limit?: number
) => Promise<T[]> {
return async <T>(
text_items: string[] | ((item: T) => string),
items: T[],
unrestricted = false,
throw_on_cancel = false,
title = "Multi Suggester",
limit?: number
): Promise<T[]> => {
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(): <T>(
text_items: string[] | ((item: T) => string),
items: T[],
Expand Down
229 changes: 229 additions & 0 deletions src/core/functions/internal_functions/system/MultiSuggesterModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import {Modal, TextComponent, fuzzySearch, prepareQuery} from "obsidian";
import {TemplaterError} from "utils/Error";

export class MultiSuggesterModal<T> extends Modal {
private resolve: (values: T[]) => void;
private reject: (reason?: TemplaterError) => void;
private selectedItems = new Set<string>();
private mappedItems = new Map<string, T>();
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<void> {
this.resolve = resolve;
this.reject = reject;
this.open();
}
}
43 changes: 43 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down