Skip to content

Commit 884e55b

Browse files
committed
add generic suggest input component
1 parent 23d1865 commit 884e55b

File tree

2 files changed

+195
-6
lines changed

2 files changed

+195
-6
lines changed

apps/obsidian/src/components/GeneralSettings.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,60 @@
1-
import React, { useState } from "react";
1+
import { useState } from "react";
22
import { usePlugin } from "./PluginContext";
33
import { Notice } from "obsidian";
4+
import SuggestInput from "./SuggestInput";
5+
6+
export const FolderSuggestInput: React.FC<{
7+
value: string;
8+
onChange: (value: string) => void;
9+
placeholder?: string;
10+
className?: string;
11+
disabled?: boolean;
12+
}> = ({
13+
value,
14+
onChange,
15+
placeholder = "Enter folder path",
16+
className = "",
17+
disabled = false,
18+
}) => {
19+
const plugin = usePlugin();
20+
21+
const getAllFolders = (): string[] => {
22+
const folders = plugin.app.vault.getAllFolders();
23+
return folders.map((folder) => folder.path).sort();
24+
};
25+
26+
const getFilteredFolders = (query: string): string[] => {
27+
const allFolders = getAllFolders();
28+
29+
if (!query.trim()) {
30+
return allFolders.slice(0, 10);
31+
}
32+
33+
return allFolders
34+
.filter((path) => path.toLowerCase().includes(query.toLowerCase()))
35+
.slice(0, 10);
36+
};
37+
38+
const renderFolder = (folder: string, el: HTMLElement) => {
39+
el.createDiv({
40+
text: folder || "(Root folder)",
41+
cls: "folder-suggestion-item",
42+
});
43+
};
44+
45+
return (
46+
<SuggestInput<string>
47+
value={value}
48+
onChange={onChange}
49+
getSuggestions={getFilteredFolders}
50+
getDisplayText={(folder) => folder}
51+
renderItem={renderFolder}
52+
placeholder={placeholder}
53+
className={className}
54+
disabled={disabled}
55+
/>
56+
);
57+
};
458

559
const GeneralSettings = () => {
660
const plugin = usePlugin();
@@ -54,17 +108,16 @@ const GeneralSettings = () => {
54108

55109
<div className="setting-item">
56110
<div className="setting-item-info">
57-
<div className="setting-item-name">Discourse nodes folder path</div>
111+
<div className="setting-item-name">Discourse Nodes folder path</div>
58112
<div className="setting-item-description">
59-
Specify the folder where new Discourse nodes should be created.
113+
Specify the folder where new Discourse Nodes should be created.
60114
Leave empty to create nodes in the root folder.
61115
</div>
62116
</div>
63117
<div className="setting-item-control">
64-
<input
65-
type="text"
118+
<FolderSuggestInput
66119
value={nodesFolderPath}
67-
onChange={(e) => handleFolderPathChange(e.target.value)}
120+
onChange={handleFolderPathChange}
68121
placeholder="Discourse Nodes"
69122
/>
70123
</div>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React, { useRef, useEffect, useState } from "react";
2+
import { AbstractInputSuggest, App } from "obsidian";
3+
import { usePlugin } from "./PluginContext";
4+
5+
class GenericSuggestInput<T> extends AbstractInputSuggest<T> {
6+
private getSuggestionsFn: (query: string) => T[];
7+
private onSelectCallback: (item: T) => void;
8+
private getDisplayTextFn: (item: T) => string;
9+
private renderItemFn?: (item: T, el: HTMLElement) => void;
10+
11+
constructor(
12+
app: App,
13+
private textInputEl: HTMLInputElement,
14+
config: {
15+
getSuggestions: (query: string) => T[];
16+
onSelect: (item: T) => void;
17+
getDisplayText: (item: T) => string;
18+
renderItem?: (item: T, el: HTMLElement) => void;
19+
},
20+
) {
21+
super(app, textInputEl);
22+
this.getSuggestionsFn = config.getSuggestions;
23+
this.onSelectCallback = config.onSelect;
24+
this.getDisplayTextFn = config.getDisplayText;
25+
this.renderItemFn = config.renderItem;
26+
}
27+
28+
getSuggestions(inputStr: string): T[] {
29+
return this.getSuggestionsFn(inputStr);
30+
}
31+
32+
renderSuggestion(item: T, el: HTMLElement): void {
33+
if (this.renderItemFn) {
34+
this.renderItemFn(item, el);
35+
} else {
36+
el.createDiv({
37+
text: this.getDisplayTextFn(item),
38+
cls: "suggestion-item",
39+
});
40+
}
41+
}
42+
43+
selectSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void {
44+
this.textInputEl.value = this.getDisplayTextFn(item);
45+
this.onSelectCallback(item);
46+
this.close();
47+
}
48+
}
49+
50+
type SuggestInputProps<T> = {
51+
value: string;
52+
onChange: (value: string) => void;
53+
getSuggestions: (query: string) => T[];
54+
getDisplayText: (item: T) => string;
55+
onSelect?: (item: T) => void;
56+
renderItem?: (item: T, el: HTMLElement) => void;
57+
placeholder?: string;
58+
className?: string;
59+
disabled?: boolean;
60+
};
61+
62+
const SuggestInput = <T,>({
63+
value,
64+
onChange,
65+
getSuggestions,
66+
getDisplayText,
67+
onSelect,
68+
renderItem,
69+
placeholder = "Enter value",
70+
className = "",
71+
disabled = false,
72+
}: SuggestInputProps<T>) => {
73+
const plugin = usePlugin();
74+
const inputRef = useRef<HTMLInputElement>(null);
75+
const [suggest, setSuggest] = useState<GenericSuggestInput<T> | null>(null);
76+
77+
const handleSelect = (item: T) => {
78+
const displayText = getDisplayText(item);
79+
onChange(displayText);
80+
onSelect?.(item);
81+
};
82+
83+
useEffect(() => {
84+
if (inputRef.current && !suggest && !disabled) {
85+
const genericSuggest = new GenericSuggestInput<T>(
86+
plugin.app,
87+
inputRef.current,
88+
{
89+
getSuggestions,
90+
onSelect: handleSelect,
91+
getDisplayText,
92+
renderItem,
93+
},
94+
);
95+
setSuggest(genericSuggest);
96+
97+
return () => {
98+
genericSuggest.close();
99+
setSuggest(null);
100+
};
101+
}
102+
}, [
103+
plugin.app,
104+
onChange,
105+
onSelect,
106+
getSuggestions,
107+
getDisplayText,
108+
renderItem,
109+
disabled,
110+
]);
111+
112+
useEffect(() => {
113+
if (inputRef.current && inputRef.current.value !== value) {
114+
inputRef.current.value = value;
115+
}
116+
}, [value]);
117+
118+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
119+
onChange(e.target.value);
120+
};
121+
122+
return (
123+
<input
124+
ref={inputRef}
125+
type="text"
126+
value={value}
127+
onChange={handleChange}
128+
placeholder={placeholder}
129+
className={className}
130+
disabled={disabled}
131+
autoComplete="off"
132+
/>
133+
);
134+
};
135+
136+
export default SuggestInput;

0 commit comments

Comments
 (0)