Skip to content

Commit 6232e21

Browse files
authored
[ENG-409] Choose folder to create nodes in (#192)
* test hiding fm setting * cur progress * feature finished * add generic suggest input component * add suggest folder
1 parent b322722 commit 6232e21

File tree

9 files changed

+264
-17
lines changed

9 files changed

+264
-17
lines changed

apps/obsidian/src/components/GeneralSettings.tsx

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

564
const GeneralSettings = () => {
665
const plugin = usePlugin();
766
const [showIdsInFrontmatter, setShowIdsInFrontmatter] = useState(
867
plugin.settings.showIdsInFrontmatter,
968
);
69+
const [nodesFolderPath, setNodesFolderPath] = useState(
70+
plugin.settings.nodesFolderPath,
71+
);
1072
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
1173

1274
const handleToggleChange = (newValue: boolean) => {
1375
setShowIdsInFrontmatter(newValue);
1476
setHasUnsavedChanges(true);
1577
};
1678

79+
const handleFolderPathChange = useCallback((newValue: string) => {
80+
setNodesFolderPath(newValue);
81+
setHasUnsavedChanges(true);
82+
}, []);
83+
1784
const handleSave = async () => {
1885
plugin.settings.showIdsInFrontmatter = showIdsInFrontmatter;
86+
plugin.settings.nodesFolderPath = nodesFolderPath;
1987
await plugin.saveSettings();
2088
new Notice("General settings saved");
2189
setHasUnsavedChanges(false);
@@ -43,6 +111,23 @@ const GeneralSettings = () => {
43111
</div>
44112
</div>
45113

114+
<div className="setting-item">
115+
<div className="setting-item-info">
116+
<div className="setting-item-name">Discourse Nodes folder path</div>
117+
<div className="setting-item-description">
118+
Specify the folder where new Discourse Nodes should be created.
119+
Leave empty to create nodes in the root folder.
120+
</div>
121+
</div>
122+
<div className="setting-item-control">
123+
<FolderSuggestInput
124+
value={nodesFolderPath}
125+
onChange={handleFolderPathChange}
126+
placeholder="Discourse Nodes"
127+
/>
128+
</div>
129+
</div>
130+
46131
<div className="setting-item">
47132
<button
48133
onClick={handleSave}

apps/obsidian/src/components/NodeTypeModal.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { App, Editor, SuggestModal, TFile, Notice } from "obsidian";
22
import { DiscourseNode } from "~/types";
33
import { processTextToDiscourseNode } from "~/utils/createNodeFromSelectedText";
4+
import type DiscourseGraphPlugin from "~/index";
45

56
export class NodeTypeModal extends SuggestModal<DiscourseNode> {
67
constructor(
7-
app: App,
88
private editor: Editor,
99
private nodeTypes: DiscourseNode[],
10+
private plugin: DiscourseGraphPlugin,
1011
) {
11-
super(app);
12+
super(plugin.app);
1213
}
1314

1415
getItemText(item: DiscourseNode): string {
@@ -28,7 +29,7 @@ export class NodeTypeModal extends SuggestModal<DiscourseNode> {
2829

2930
async onChooseSuggestion(nodeType: DiscourseNode) {
3031
await processTextToDiscourseNode({
31-
app: this.app,
32+
plugin: this.plugin,
3233
editor: this.editor,
3334
nodeType,
3435
});

apps/obsidian/src/components/Settings.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const Settings = () => {
6363
{activeTab === "nodeTypes" && <NodeTypeSettings />}
6464
{activeTab === "relationTypes" && <RelationshipTypeSettings />}
6565
{activeTab === "relations" && <RelationshipSettings />}
66+
{activeTab === "frontmatter" && <GeneralSettings />}
6667
</div>
6768
);
6869
};
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { useRef, useEffect, useState, useCallback } 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 = useCallback(
78+
(item: T) => {
79+
const displayText = getDisplayText(item);
80+
onChange(displayText);
81+
onSelect?.(item);
82+
},
83+
[getDisplayText, onChange, onSelect],
84+
);
85+
86+
useEffect(() => {
87+
if (inputRef.current && !suggest && !disabled) {
88+
const genericSuggest = new GenericSuggestInput<T>(
89+
plugin.app,
90+
inputRef.current,
91+
{
92+
getSuggestions,
93+
onSelect: handleSelect,
94+
getDisplayText,
95+
renderItem,
96+
},
97+
);
98+
setSuggest(genericSuggest);
99+
100+
return () => {
101+
genericSuggest.close();
102+
setSuggest(null);
103+
};
104+
}
105+
}, [
106+
plugin.app,
107+
getSuggestions,
108+
getDisplayText,
109+
renderItem,
110+
disabled,
111+
handleSelect,
112+
]);
113+
114+
useEffect(() => {
115+
if (inputRef.current && inputRef.current.value !== value) {
116+
inputRef.current.value = value;
117+
}
118+
}, [value]);
119+
120+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
121+
onChange(e.target.value);
122+
};
123+
124+
return (
125+
<input
126+
ref={inputRef}
127+
type="text"
128+
value={value}
129+
onChange={handleChange}
130+
placeholder={placeholder}
131+
className={className}
132+
disabled={disabled}
133+
autoComplete="off"
134+
/>
135+
);
136+
};
137+
138+
export default SuggestInput;

apps/obsidian/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,5 @@ export const DEFAULT_SETTINGS: Settings = {
5757
},
5858
],
5959
showIdsInFrontmatter: true,
60+
nodesFolderPath: "Discourse Nodes",
6061
};

apps/obsidian/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default class DiscourseGraphPlugin extends Plugin {
4747
.setIcon("file-type")
4848
.onClick(async () => {
4949
await processTextToDiscourseNode({
50-
app: this.app,
50+
plugin: this,
5151
editor,
5252
nodeType,
5353
});

apps/obsidian/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type Settings = {
2424
discourseRelations: DiscourseRelation[];
2525
relationTypes: DiscourseRelationType[];
2626
showIdsInFrontmatter: boolean;
27+
nodesFolderPath: string;
2728
};
2829

2930
export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view";

0 commit comments

Comments
 (0)