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 4 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.multiselect]
sujyotraut marked this conversation as resolved.
Show resolved Hide resolved
name = "multiselect"
description = "Spawns a multiselect prompt and returns the user's chosen items."
definition = "tp.system.multiselect(text_items: string[] ⎮ ((item: T) => string), items: T[], unrestricted: boolean = false, throw_on_cancel: boolean = false, title: string = \"Multiselect\", limit?: number = undefined)"

[tp.system.functions.multiselect.args.text_items]
name = "text_items"
description = "Array of strings representing the text that will be displayed for each item in the multiselect prompt. This can also be a function that maps an item to its text representation."

[tp.system.functions.multiselect.args.items]
name = "items"
description = "Array containing the values of each item in the correct order."

[tp.system.functions.multiselect.args.unrestricted]
name = "unrestricted"
description = "A boolean which represents whether the values outside suggestions can be selected or not"

[tp.system.functions.multiselect.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.multiselect.args.title]
name = "title"
description = "Title of multiselect prompt"

[tp.system.functions.multiselect.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
Expand Up @@ -3,6 +3,7 @@ import { InternalModule } from "../InternalModule";
import { Platform } from "obsidian";
import { PromptModal } from "./PromptModal";
import { SuggesterModal } from "./SuggesterModal";
import { MultiSelectModal } from "./MultiSelectModal";
import { TemplaterError } from "utils/Error";
import { ModuleName } from "editor/TpDocumentation";

Expand All @@ -12,6 +13,7 @@ export class InternalModuleSystem extends InternalModule {
async create_static_templates(): Promise<void> {
this.static_functions.set("clipboard", this.generate_clipboard());
this.static_functions.set("prompt", this.generate_prompt());
this.static_functions.set("multiselect", this.generate_multiselect());
this.static_functions.set("suggester", this.generate_suggester());
}

Expand Down Expand Up @@ -61,6 +63,46 @@ export class InternalModuleSystem extends InternalModule {
};
}

generate_multiselect(): <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 = "Multiselect",
limit?: number
): Promise<T[]> => {
const multiselect = new MultiSelectModal(
text_items,
items,
unrestricted,
title,
limit
);
const promise = new Promise(
(
resolve: (values: T[]) => void,
reject: (reason?: TemplaterError) => void
) => multiselect.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/MultiSelectModal.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 MultiSelectModal<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(
sujyotraut marked this conversation as resolved.
Show resolved Hide resolved
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":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why, but hitting the Enter key while in the input is also adding a newline in the editor, causing a conflict and the following notice to pop up.

image

I also feels a bit off to have to click on the input then hit the Enter key if you're clicking or tapping on options instead of using the keyboard and hitting the Tab key to select options.

Maybe hitting the Enter key while the modal is open should close the modal, without having to have the input focused?

Might also be worth adding an "Okay" or "Done" button to make it easier for mobile and touch users.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also feels a bit off to have to click on the input then hit the Enter key if you're clicking or tapping on options instead of using the keyboard and hitting the Tab key to select options.

I will work on it so that we don't have to click on input field to select options and add "Done" button to make more mobile friendly.

I am trying to figure out how to fix the issue (adds a newline in the editor) but didn't get any results if you have any suggestions please fill free to let me know.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to find some time to figure out why it's adding newlines. I don't think the suggester does this, or if it does, I haven't run into that issue yet.

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() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this function is called, it isn't adding back the item to the list of available suggestions. I think it should be added back as an available item to select.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean removeLastItem() function?
It removes the selected item and re-renders suggestions by calling renderSuggestions().
renderSuggestions() function filters out items which are selected and renders suggestions which are not selected.
Since we remove the selected item in removeLastItem() it should be available for suggestions.

If you mean something else, can you please provide me more details.

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