Skip to content

Commit d6ef70c

Browse files
authored
feat: add tp.system.multi_suggester (#1639)
* feat: add `tp.system.multi_suggester` refs: #461, #1130 * Use title instead of placeholder for multi_suggester module * docs: document `tp.system.multi_suggester` module refs: #461, #1130 * cleanup refs: #461, #1130 --------- Co-authored-by: Zachatoo <[email protected]>
1 parent 7c5b8c6 commit d6ef70c

File tree

4 files changed

+267
-2
lines changed

4 files changed

+267
-2
lines changed

docs/documentation.toml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,55 @@ let selectedValue = await tp.system.suggester(["Happy", "Sad", "Confused"], ["Ha
601601
# <% selectedValue %>
602602
selected value: <% selectedValue %>"""
603603

604+
[tp.system.functions.multi_suggester]
605+
name = "multi_suggester"
606+
description = "Spawns a suggester prompt that supports selecting multiple items and returns the user's chosen items."
607+
definition = "tp.system.multi_suggester(text_items: string[] ⎮ ((item: T) => string), items: T[], throw_on_cancel: boolean = false, title: string = \"\", limit?: number = undefined)"
608+
609+
[[tp.system.functions.multi_suggester.args]]
610+
name = "text_items"
611+
description = "Array of strings representing the text that will be displayed for each item in the suggester prompt. This can also be a function that maps an item to its text representation."
612+
613+
[[tp.system.functions.multi_suggester.args]]
614+
name = "items"
615+
description = "Array containing the values of each item in the correct order."
616+
617+
[[tp.system.functions.multi_suggester.args]]
618+
name = "throw_on_cancel"
619+
description = "Throws an error if the prompt is canceled, instead of returning a `null` value."
620+
621+
[[tp.system.functions.multi_suggester.args]]
622+
name = "title"
623+
description = "Text placed at the top of the modal."
624+
625+
[[tp.system.functions.multi_suggester.args]]
626+
name = "limit"
627+
description = "Limit the number of items rendered at once (useful to improve performance when displaying large lists)."
628+
629+
[[tp.system.functions.multi_suggester.examples]]
630+
name = "Multi-suggester"
631+
example = """<% await tp.system.multi_suggester(["Happy", "Sad", "Confused"], ["Happy", "Sad", "Confused"]) %>"""
632+
633+
[[tp.system.functions.multi_suggester.examples]]
634+
name = "Multi-suggester with mapping function (same as above example)"
635+
example = """<% await tp.system.multi_suggester((item) => item, ["Happy", "Sad", "Confused"]) %>"""
636+
637+
[[tp.system.functions.multi_suggester.examples]]
638+
name = "Multi-suggester for files"
639+
example = """<% (await tp.system.multi_suggester((item) => item.basename, tp.app.vault.getMarkdownFiles())).map(f => `[[${f.basename}]]`) %>"""
640+
641+
[[tp.system.functions.multi_suggester.examples]]
642+
name = "Multi-suggester for tags"
643+
example = """<% await tp.system.multi_suggester(item => item, Object.keys(tp.app.metadataCache.getTags()).map(x => x.replace("#", ""))) %>"""
644+
645+
[[tp.system.functions.multi_suggester.examples]]
646+
name = "Reuse value from multi-suggester"
647+
example = """<%*
648+
let selectedValues = await tp.system.multi_suggester(["Happy", "Sad", "Confused"], ["Happy", "Sad", "Confused"]);
649+
%>
650+
# <% selectedValues %>
651+
selected values: <% selectedValues %>"""
652+
604653
[tp.web]
605654
name = "web"
606655
description = "This modules contains every internal function related to the web (making web requests)."

src/core/functions/internal_functions/system/InternalModuleSystem.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PromptModal } from "./PromptModal";
33
import { SuggesterModal } from "./SuggesterModal";
44
import { TemplaterError } from "utils/Error";
55
import { ModuleName } from "editor/TpDocumentation";
6+
import { MultiSuggesterModal } from "./MultiSuggesterModal";
67

78
export class InternalModuleSystem extends InternalModule {
89
public name: ModuleName = "system";
@@ -11,6 +12,10 @@ export class InternalModuleSystem extends InternalModule {
1112
this.static_functions.set("clipboard", this.generate_clipboard());
1213
this.static_functions.set("prompt", this.generate_prompt());
1314
this.static_functions.set("suggester", this.generate_suggester());
15+
this.static_functions.set(
16+
"multi_suggester",
17+
this.generate_multi_suggester()
18+
);
1419
}
1520

1621
async create_dynamic_templates(): Promise<void> {}
@@ -95,4 +100,42 @@ export class InternalModuleSystem extends InternalModule {
95100
}
96101
};
97102
}
103+
104+
generate_multi_suggester(): <T>(
105+
text_items: string[] | ((item: T) => string),
106+
items: T[],
107+
throw_on_cancel: boolean,
108+
title: string,
109+
limit?: number
110+
) => Promise<T[]> {
111+
return async <T>(
112+
text_items: string[] | ((item: T) => string),
113+
items: T[],
114+
throw_on_cancel = false,
115+
title = "",
116+
limit?: number
117+
): Promise<T[]> => {
118+
const suggester = new MultiSuggesterModal(
119+
this.plugin.app,
120+
text_items,
121+
items,
122+
title,
123+
limit
124+
);
125+
const promise = new Promise(
126+
(
127+
resolve: (values: T[]) => void,
128+
reject: (reason?: TemplaterError) => void
129+
) => suggester.openAndGetValue(resolve, reject)
130+
);
131+
try {
132+
return await promise;
133+
} catch (error) {
134+
if (throw_on_cancel) {
135+
throw error;
136+
}
137+
return [];
138+
}
139+
};
140+
}
98141
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { TemplaterError } from "utils/Error";
2+
import {
3+
AbstractInputSuggest,
4+
App,
5+
ButtonComponent,
6+
Modal,
7+
prepareFuzzySearch,
8+
setIcon,
9+
TextComponent,
10+
} from "obsidian";
11+
12+
export class MultiSuggesterModal<T> extends Modal {
13+
private resolve: (values: T[]) => void;
14+
private reject: (reason?: TemplaterError) => void;
15+
private submitted = false;
16+
private selectedItems: T[] = [];
17+
private listEl: HTMLDivElement;
18+
private suggester: InstanceType<typeof Suggester>;
19+
20+
constructor(
21+
app: App,
22+
private text_items: string[] | ((item: T) => string),
23+
private items: T[],
24+
title: string,
25+
limit?: number
26+
) {
27+
super(app);
28+
this.setTitle(title);
29+
this.listEl = this.contentEl.createDiv("templater-multisuggester-list");
30+
const inputContainer = this.contentEl.createDiv(
31+
"templater-multisuggester-div"
32+
);
33+
const inputComponent = new TextComponent(inputContainer);
34+
inputComponent.inputEl.addClass("templater-multisuggester-input");
35+
this.suggester = new Suggester(
36+
app,
37+
inputComponent.inputEl,
38+
this.getItemText.bind(this),
39+
items,
40+
limit
41+
).onSelect(this.onChooseItem.bind(this));
42+
const buttonContainer = this.contentEl.createDiv(
43+
"modal-button-container"
44+
);
45+
new ButtonComponent(buttonContainer)
46+
.setButtonText("Save")
47+
.setCta()
48+
.onClick(() => this.save());
49+
new ButtonComponent(buttonContainer)
50+
.setButtonText("Cancel")
51+
.onClick(() => this.close());
52+
}
53+
54+
onOpen(): void {
55+
this.display();
56+
}
57+
58+
display(): void {
59+
this.listEl.empty();
60+
this.selectedItems.forEach((item) => {
61+
const itemEl = this.listEl.createDiv("mobile-option-setting-item");
62+
itemEl
63+
.createSpan("mobile-option-setting-item-name")
64+
.setText(this.getItemText(item));
65+
itemEl.createDiv(
66+
"clickable-icon mobile-option-setting-item-option-icon",
67+
(deleteEl) => {
68+
setIcon(deleteEl, "lucide-x");
69+
deleteEl.addEventListener("click", () => {
70+
this.onRemoveItem(item);
71+
});
72+
}
73+
);
74+
});
75+
}
76+
77+
getItemText(item: T): string {
78+
if (this.text_items instanceof Function) {
79+
return this.text_items(item);
80+
}
81+
return (
82+
this.text_items[this.items.indexOf(item)] || "Undefined Text Item"
83+
);
84+
}
85+
86+
onChooseItem(item: T): void {
87+
this.selectedItems.push(item);
88+
const filteredItems = this.items.filter((item) => {
89+
return !this.selectedItems.some(
90+
(selected_item) => selected_item === item
91+
);
92+
});
93+
this.suggester.setItems(filteredItems);
94+
this.display();
95+
}
96+
97+
onRemoveItem(item: T): void {
98+
this.selectedItems = this.selectedItems.filter(
99+
(selectedItem) => selectedItem !== item
100+
);
101+
const filteredItems = this.items.filter((item) => {
102+
return !this.selectedItems.some(
103+
(selected_item) => selected_item === item
104+
);
105+
});
106+
this.suggester.setItems(filteredItems);
107+
this.display();
108+
}
109+
110+
save(): void {
111+
this.submitted = true;
112+
this.close();
113+
this.resolve(this.selectedItems);
114+
}
115+
116+
onClose(): void {
117+
if (!this.submitted) {
118+
this.reject(new TemplaterError("Cancelled prompt"));
119+
}
120+
}
121+
122+
async openAndGetValue(
123+
resolve: (values: T[]) => void,
124+
reject: (reason?: TemplaterError) => void
125+
): Promise<void> {
126+
this.resolve = resolve;
127+
this.reject = reject;
128+
this.open();
129+
}
130+
}
131+
132+
class Suggester<T> extends AbstractInputSuggest<T> {
133+
constructor(
134+
app: App,
135+
textInputEl: HTMLInputElement | HTMLDivElement,
136+
private getItemText: (item: T) => string,
137+
private items: T[],
138+
limit?: number
139+
) {
140+
super(app, textInputEl);
141+
limit && (this.limit = limit);
142+
}
143+
protected getSuggestions(query: string): T[] | Promise<T[]> {
144+
const q = prepareFuzzySearch(query);
145+
return this.items.reduce((acc, item) => {
146+
const itemText = this.getItemText(item);
147+
if (q(itemText)) {
148+
acc.push(item);
149+
}
150+
return acc;
151+
}, [] as T[]);
152+
}
153+
154+
renderSuggestion(value: T, el: HTMLElement): void {
155+
el.createDiv("suggestion-content").setText(this.getItemText(value));
156+
}
157+
158+
setItems(items: T[]) {
159+
this.items = items;
160+
}
161+
162+
selectSuggestion(value: T, evt: MouseEvent | KeyboardEvent) {
163+
this.setValue("");
164+
this.close();
165+
super.selectSuggestion(value, evt);
166+
}
167+
}

styles.css

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
justify-content: center;
5555
}
5656

57-
.templater-prompt-div {
57+
.templater-prompt-div,
58+
.templater-multisuggester-div {
5859
display: flex;
5960
}
6061

@@ -63,7 +64,8 @@
6364
flex-grow: 1;
6465
}
6566

66-
.templater-prompt-input {
67+
.templater-prompt-input,
68+
.templater-multisuggester-input {
6769
flex-grow: 1;
6870
}
6971

@@ -82,6 +84,10 @@ textarea.templater-prompt-input:focus {
8284
border-color: var(--interactive-accent);
8385
}
8486

87+
.templater-multisuggester-list {
88+
margin: 1.5em 0;
89+
}
90+
8591
.cm-s-obsidian .templater-command-bg {
8692
left: 0px;
8793
right: 0px;

0 commit comments

Comments
 (0)