Skip to content

Commit 27049c6

Browse files
authored
[ENG-410] Node creation dialog (#194)
* test hiding fm setting * cur progress * create from no selected text * add command * address PR comments * address PR comments
1 parent 6232e21 commit 27049c6

5 files changed

Lines changed: 255 additions & 20 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { App, Modal, Notice } from "obsidian";
2+
import { createRoot, Root } from "react-dom/client";
3+
import { StrictMode, useState, useEffect, useRef } from "react";
4+
import { DiscourseNode } from "~/types";
5+
import type DiscourseGraphPlugin from "~/index";
6+
7+
type CreateNodeFormProps = {
8+
nodeTypes: DiscourseNode[];
9+
plugin: DiscourseGraphPlugin;
10+
onNodeCreate: (nodeType: DiscourseNode, title: string) => Promise<void>;
11+
onCancel: () => void;
12+
};
13+
14+
export function CreateNodeForm({
15+
nodeTypes,
16+
plugin,
17+
onNodeCreate,
18+
onCancel,
19+
}: CreateNodeFormProps) {
20+
const [title, setTitle] = useState("");
21+
const [selectedNodeType, setSelectedNodeType] =
22+
useState<DiscourseNode | null>(null);
23+
const [isSubmitting, setIsSubmitting] = useState(false);
24+
const titleInputRef = useRef<HTMLInputElement>(null);
25+
26+
useEffect(() => {
27+
// Focus the title input when component mounts
28+
setTimeout(() => {
29+
titleInputRef.current?.focus();
30+
}, 50);
31+
}, []);
32+
33+
const isFormValid = title.trim() && selectedNodeType;
34+
35+
const handleKeyDown = (e: React.KeyboardEvent) => {
36+
if (e.key === "Enter" && !e.shiftKey) {
37+
e.preventDefault();
38+
handleConfirm();
39+
} else if (e.key === "Escape") {
40+
e.preventDefault();
41+
onCancel();
42+
}
43+
};
44+
45+
const handleNodeTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
46+
const selectedId = e.target.value;
47+
setSelectedNodeType(nodeTypes.find((nt) => nt.id === selectedId) || null);
48+
};
49+
50+
const handleConfirm = async () => {
51+
if (!isFormValid || isSubmitting) {
52+
return;
53+
}
54+
55+
const trimmedTitle = title.trim();
56+
if (!trimmedTitle) {
57+
new Notice("Please enter a title", 3000);
58+
return;
59+
}
60+
61+
if (!selectedNodeType) {
62+
new Notice("Please select a node type", 3000);
63+
return;
64+
}
65+
66+
try {
67+
setIsSubmitting(true);
68+
await onNodeCreate(selectedNodeType, trimmedTitle);
69+
onCancel(); // Close the modal on success
70+
} catch (error) {
71+
console.error("Error creating node:", error);
72+
new Notice(
73+
`Error creating node: ${error instanceof Error ? error.message : String(error)}`,
74+
5000,
75+
);
76+
} finally {
77+
setIsSubmitting(false);
78+
}
79+
};
80+
81+
return (
82+
<div>
83+
<h2>Create Discourse Node</h2>
84+
85+
<div className="setting-item">
86+
<div className="setting-item-name">Title</div>
87+
<div className="setting-item-control">
88+
<input
89+
ref={titleInputRef}
90+
type="text"
91+
placeholder="Title"
92+
value={title}
93+
onChange={(e) => setTitle(e.target.value)}
94+
onKeyDown={handleKeyDown}
95+
disabled={isSubmitting}
96+
className="resize-vertical font-inherit border-background-modifier-border bg-background-primary text-text-normal max-h-[6em] min-h-[2.5em] w-full overflow-y-auto rounded-md border p-2"
97+
/>
98+
</div>
99+
</div>
100+
101+
<div className="setting-item">
102+
<div className="setting-item-name">Type</div>
103+
<div className="setting-item-control">
104+
<select
105+
value={selectedNodeType?.id || ""}
106+
onChange={handleNodeTypeChange}
107+
disabled={isSubmitting}
108+
className="w-full"
109+
>
110+
<option value="">Select node type</option>
111+
{nodeTypes.map((nodeType) => (
112+
<option key={nodeType.id} value={nodeType.id}>
113+
{nodeType.name}
114+
</option>
115+
))}
116+
</select>
117+
</div>
118+
</div>
119+
120+
<div
121+
className="modal-button-container"
122+
style={{
123+
display: "flex",
124+
justifyContent: "flex-end",
125+
gap: "8px",
126+
marginTop: "20px",
127+
}}
128+
>
129+
<button
130+
type="button"
131+
className="mod-normal"
132+
onClick={onCancel}
133+
disabled={isSubmitting}
134+
>
135+
Cancel
136+
</button>
137+
<button
138+
type="button"
139+
className="mod-cta"
140+
onClick={handleConfirm}
141+
disabled={!isFormValid || isSubmitting}
142+
>
143+
{isSubmitting ? "Creating..." : "Confirm"}
144+
</button>
145+
</div>
146+
</div>
147+
);
148+
}
149+
150+
type CreateNodeModalProps = {
151+
nodeTypes: DiscourseNode[];
152+
plugin: DiscourseGraphPlugin;
153+
onNodeCreate: (nodeType: DiscourseNode, title: string) => Promise<void>;
154+
};
155+
156+
export class CreateNodeModal extends Modal {
157+
private nodeTypes: DiscourseNode[];
158+
private plugin: DiscourseGraphPlugin;
159+
private onNodeCreate: (
160+
nodeType: DiscourseNode,
161+
title: string,
162+
) => Promise<void>;
163+
private root: Root | null = null;
164+
165+
constructor(app: App, props: CreateNodeModalProps) {
166+
super(app);
167+
this.nodeTypes = props.nodeTypes;
168+
this.plugin = props.plugin;
169+
this.onNodeCreate = props.onNodeCreate;
170+
}
171+
172+
onOpen() {
173+
const { contentEl } = this;
174+
contentEl.empty();
175+
176+
this.root = createRoot(contentEl);
177+
this.root.render(
178+
<StrictMode>
179+
<CreateNodeForm
180+
nodeTypes={this.nodeTypes}
181+
plugin={this.plugin}
182+
onNodeCreate={this.onNodeCreate}
183+
onCancel={() => this.close()}
184+
/>
185+
</StrictMode>,
186+
);
187+
}
188+
189+
onClose() {
190+
if (this.root) {
191+
this.root.unmount();
192+
this.root = null;
193+
}
194+
const { contentEl } = this;
195+
contentEl.empty();
196+
}
197+
}

apps/obsidian/src/components/NodeTypeModal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { App, Editor, SuggestModal, TFile, Notice } from "obsidian";
22
import { DiscourseNode } from "~/types";
3-
import { processTextToDiscourseNode } from "~/utils/createNodeFromSelectedText";
3+
import { createDiscourseNode } from "~/utils/createNode";
44
import type DiscourseGraphPlugin from "~/index";
55

66
export class NodeTypeModal extends SuggestModal<DiscourseNode> {
@@ -28,10 +28,11 @@ export class NodeTypeModal extends SuggestModal<DiscourseNode> {
2828
}
2929

3030
async onChooseSuggestion(nodeType: DiscourseNode) {
31-
await processTextToDiscourseNode({
31+
await createDiscourseNode({
3232
plugin: this.plugin,
3333
editor: this.editor,
3434
nodeType,
35+
text: this.editor.getSelection().trim() || "",
3536
});
3637
}
3738
}

apps/obsidian/src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { Settings } from "~/types";
44
import { registerCommands } from "~/utils/registerCommands";
55
import { DiscourseContextView } from "~/components/DiscourseContextView";
66
import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
7-
import { processTextToDiscourseNode } from "./utils/createNodeFromSelectedText";
8-
import { DEFAULT_SETTINGS } from "./constants";
7+
import { createDiscourseNode } from "~/utils/createNode";
8+
import { DEFAULT_SETTINGS } from "~/constants";
99

1010
export default class DiscourseGraphPlugin extends Plugin {
1111
settings: Settings = { ...DEFAULT_SETTINGS };
@@ -46,10 +46,11 @@ export default class DiscourseGraphPlugin extends Plugin {
4646
.setTitle(nodeType.name)
4747
.setIcon("file-type")
4848
.onClick(async () => {
49-
await processTextToDiscourseNode({
49+
await createDiscourseNode({
5050
plugin: this,
5151
editor,
5252
nodeType,
53+
text: editor.getSelection().trim() || "",
5354
});
5455
});
5556
});

apps/obsidian/src/utils/createNodeFromSelectedText.ts renamed to apps/obsidian/src/utils/createNode.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import { applyTemplate } from "./templates";
66
import type DiscourseGraphPlugin from "~/index";
77

88
export const formatNodeName = (
9-
selectedText: string,
9+
text: string,
1010
nodeType: DiscourseNode,
1111
): string | null => {
12+
const normalizedText = text.replace(/\s*\n\s*/g, " ").trim();
13+
1214
const regex = getDiscourseNodeFormatExpression(nodeType.format);
1315
const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/);
1416

1517
if (!nodeFormat) return null;
1618

1719
return (
1820
nodeFormat[1]?.replace(/\\/g, "") +
19-
selectedText +
21+
normalizedText +
2022
nodeFormat[2]?.replace(/\\/g, "")
2123
);
2224
};
@@ -105,18 +107,18 @@ export const createDiscourseNodeFile = async ({
105107
}
106108
};
107109

108-
export const processTextToDiscourseNode = async ({
110+
export const createDiscourseNode = async ({
109111
plugin,
110-
editor,
111112
nodeType,
113+
text,
114+
editor,
112115
}: {
113116
plugin: DiscourseGraphPlugin;
114-
editor: Editor;
115117
nodeType: DiscourseNode;
118+
text: string;
119+
editor?: Editor;
116120
}): Promise<TFile | null> => {
117-
const selectedText = editor.getSelection().trim();
118-
119-
const formattedNodeName = formatNodeName(selectedText, nodeType);
121+
const formattedNodeName = formatNodeName(text, nodeType);
120122
if (!formattedNodeName) return null;
121123

122124
const isFilenameValid = checkInvalidChars(formattedNodeName);
@@ -130,9 +132,10 @@ export const processTextToDiscourseNode = async ({
130132
formattedNodeName,
131133
nodeType,
132134
});
133-
if (newFile) {
135+
136+
if (newFile && editor) {
134137
editor.replaceSelection(`[[${formattedNodeName}]]`);
135138
}
136139

137140
return newFile;
138-
};
141+
};

apps/obsidian/src/utils/registerCommands.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,52 @@
1-
import { Editor, Notice } from "obsidian";
1+
import { Editor } from "obsidian";
22
import type DiscourseGraphPlugin from "~/index";
33
import { NodeTypeModal } from "~/components/NodeTypeModal";
4+
import { CreateNodeModal } from "~/components/CreateNodeModal";
5+
import { createDiscourseNode } from "./createNode";
46

57
export const registerCommands = (plugin: DiscourseGraphPlugin) => {
68
plugin.addCommand({
79
id: "open-node-type-menu",
810
name: "Open Node Type Menu",
911
hotkeys: [{ modifiers: ["Mod"], key: "\\" }],
1012
editorCallback: (editor: Editor) => {
11-
if (!editor.getSelection()) {
12-
new Notice("Please select some text to create a discourse node", 3000);
13-
return;
13+
const hasSelection = !!editor.getSelection();
14+
15+
if (hasSelection) {
16+
new NodeTypeModal(editor, plugin.settings.nodeTypes, plugin).open();
17+
} else {
18+
new CreateNodeModal(plugin.app, {
19+
nodeTypes: plugin.settings.nodeTypes,
20+
plugin,
21+
onNodeCreate: async (nodeType, title) => {
22+
await createDiscourseNode({
23+
plugin,
24+
nodeType,
25+
text: title,
26+
editor,
27+
});
28+
},
29+
}).open();
1430
}
31+
},
32+
});
1533

16-
new NodeTypeModal(editor, plugin.settings.nodeTypes, plugin).open();
34+
plugin.addCommand({
35+
id: "create-discourse-node",
36+
name: "Create Discourse Node",
37+
editorCallback: (editor: Editor) => {
38+
new CreateNodeModal(plugin.app, {
39+
nodeTypes: plugin.settings.nodeTypes,
40+
plugin,
41+
onNodeCreate: async (nodeType, title) => {
42+
await createDiscourseNode({
43+
plugin,
44+
nodeType,
45+
text: title,
46+
editor,
47+
});
48+
},
49+
}).open();
1750
},
1851
});
1952

0 commit comments

Comments
 (0)