From 8d968e14c7d28d876edaf1d22de33720886d9f63 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 12 Feb 2025 22:23:13 -0600 Subject: [PATCH 01/14] Rename trang.JPG to trang.jpg --- apps/website/public/team/trang.jpg | 1 + 1 file changed, 1 insertion(+) create mode 100755 apps/website/public/team/trang.jpg diff --git a/apps/website/public/team/trang.jpg b/apps/website/public/team/trang.jpg new file mode 100755 index 000000000..d3f5a12fa --- /dev/null +++ b/apps/website/public/team/trang.jpg @@ -0,0 +1 @@ + From d4c636b4cb46c65e3b9d9e285d9a0aca2b4379a3 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 12 Feb 2025 22:35:30 -0600 Subject: [PATCH 02/14] remove trang.jpg --- apps/website/public/team/trang.jpg | 1 - 1 file changed, 1 deletion(-) delete mode 100755 apps/website/public/team/trang.jpg diff --git a/apps/website/public/team/trang.jpg b/apps/website/public/team/trang.jpg deleted file mode 100755 index d3f5a12fa..000000000 --- a/apps/website/public/team/trang.jpg +++ /dev/null @@ -1 +0,0 @@ - From 95d426f4a4feeb12c36ddc878cca33e882723c7a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 17 Feb 2025 21:07:15 +0700 Subject: [PATCH 03/14] curr progress --- apps/obsidian/src/components/Settings.tsx | 144 ++++++++++++++++--- apps/obsidian/src/components/SettingsTab.tsx | 56 ++++++++ apps/obsidian/src/index.ts | 25 +++- apps/obsidian/src/types.ts | 10 ++ apps/obsidian/src/utils/registerCommands.ts | 2 + 5 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 apps/obsidian/src/components/SettingsTab.tsx create mode 100644 apps/obsidian/src/types.ts diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index cfe4d098c..8944eb395 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -4,13 +4,117 @@ import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; -const Settings = () => { +const NodeTypeSettings = ({ + nodeTypes, + onNodeTypeChange, + onAddNodeType, +}: { + nodeTypes: Array<{ name: string; format: string }>; + onNodeTypeChange: ( + index: number, + field: "name" | "format", + value: string, + ) => Promise; + onAddNodeType: () => Promise; +}) => { + return ( +
+

Node Types

+ {nodeTypes.map((nodeType, index) => ( +
+
+ onNodeTypeChange(index, "name", e.target.value)} + style={{ flex: 1 }} + /> + + onNodeTypeChange(index, "format", e.target.value) + } + style={{ flex: 2 }} + /> +
+
+ ))} +
+ +
+
+ ); +}; + +const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const app = useApp(); if (!app) { - return
An error occured
; + return
An error occurred
; + } + + // Initialize nodeTypes if undefined + if (!plugin.settings.nodeTypes) { + plugin.settings.nodeTypes = []; } - return

Settings for {app.vault.getName()}

; + const handleNodeTypeChange = async ( + index: number, + field: "name" | "format", + value: string, + ) => { + if (!plugin.settings.nodeTypes[index]) { + plugin.settings.nodeTypes[index] = { name: "", format: "" }; + } + plugin.settings.nodeTypes[index][field] = value; + await plugin.saveSettings(); + }; + + const handleAddNodeType = async () => { + plugin.settings.nodeTypes.push({ + name: "", + format: "", + }); + await plugin.saveSettings(); + }; +); + + + + return ( +
+

Discourse Graph Settings

+ {/* Original setting */} + +
+
+
Setting #1
+
It's a secret
+
+
+ { + plugin.settings.mySetting = e.target.value; + await plugin.saveSettings(); + }} + /> +
+
+
+ {/* Node Type Settings */} + +

Settings for {app.vault.getName()}

; +
+ ); }; export class SettingsTab extends PluginSettingTab { @@ -26,30 +130,30 @@ export class SettingsTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - // Example react component in settings + // Example obsidian settings +const obsidianSettingsEl = containerEl.createDiv(); +new Setting(obsidianSettingsEl) + .setName("Setting #1") + .setDesc("It's a secret") + .addText((text) => + text + .setPlaceholder("Enter your secret") + .setValue(this.plugin.settings.mySetting) + .onChange(async (value) => { + this.plugin.settings.mySetting = value; + await this.plugin.saveSettings(); + }), + ); + + const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); this.root.render( - + , ); - - // Example obsidian settings - const obsidianSettingsEl = containerEl.createDiv(); - new Setting(obsidianSettingsEl) - .setName("Setting #1") - .setDesc("It's a secret") - .addText((text) => - text - .setPlaceholder("Enter your secret") - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }), - ); } } diff --git a/apps/obsidian/src/components/SettingsTab.tsx b/apps/obsidian/src/components/SettingsTab.tsx new file mode 100644 index 000000000..fcd44adbb --- /dev/null +++ b/apps/obsidian/src/components/SettingsTab.tsx @@ -0,0 +1,56 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import DiscourseGraphPlugin from "../index"; + +export class SettingsTab extends PluginSettingTab { + plugin: DiscourseGraphPlugin; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "Discourse Graph Settings" }); + + // Node Types Section + containerEl.createEl("h3", { text: "Node Types" }); + + this.plugin.settings.nodeTypes.forEach((nodeType, index) => { + new Setting(containerEl) + .setName(`Node Type ${index + 1}`) + .addText((text) => + text + .setPlaceholder("Name") + .setValue(nodeType.name) + .onChange(async (value) => { + this.plugin.settings.nodeTypes[index].name = value; + await this.plugin.saveSettings(); + }), + ) + .addText((text) => + text + .setPlaceholder("Format (e.g., [[CLM]] - {content})") + .setValue(nodeType.format) + .onChange(async (value) => { + this.plugin.settings.nodeTypes[index].format = value; + await this.plugin.saveSettings(); + }), + ); + }); + + // Add New Node Type Button + new Setting(containerEl).addButton((btn) => + btn.setButtonText("Add Node Type").onClick(async () => { + this.plugin.settings.nodeTypes.push({ + name: "", + format: "", + }); + await this.plugin.saveSettings(); + this.display(); + }), + ); + } +} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 021a7d356..4ed49e578 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,20 +1,43 @@ import { Plugin } from "obsidian"; import { registerCommands } from "~/utils/registerCommands"; import { SettingsTab } from "~/components/Settings"; +import { DiscourseNodeType } from "./types"; type Settings = { mySetting: string; + nodeTypes: DiscourseNodeType[]; }; const DEFAULT_SETTINGS: Settings = { mySetting: "default", + nodeTypes: [ + { + name: "Claim", + format: "[[CLM]] - {content}", + shortcut: "C", + color: "#7DA13E", + }, + { + name: "Question", + format: "[[QUE]] - {content}", + shortcut: "Q", + color: "#99890e", + }, + { + name: "Evidence", + format: "[[EVD]] - {content}", + shortcut: "E", + color: "#DB134A", + }, + ], }; export default class DiscourseGraphPlugin extends Plugin { - settings: Settings = { mySetting: "default" }; + settings: Settings = { mySetting: "default", nodeTypes: [] }; async onload() { await this.loadSettings(); + console.log("DiscourseGraphPlugin loaded"); registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); } diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts new file mode 100644 index 000000000..f364edf91 --- /dev/null +++ b/apps/obsidian/src/types.ts @@ -0,0 +1,10 @@ +export type DiscourseNodeType = { + name: string; + format: string; + shortcut?: string; + color?: string; +}; + +export type PluginSettings = { + nodeTypes: DiscourseNodeType[]; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index f2235bf3e..3f5bd36ab 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -22,6 +22,8 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + console.log(plugin.settings); + // This adds a complex command that can check whether the current state of the app allows execution of the command plugin.addCommand({ id: "open-sample-modal-complex", From 4a72b976f798acca4740127f8239c10565863cdc Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 21 Feb 2025 17:57:13 -0800 Subject: [PATCH 04/14] finished current settings --- apps/obsidian/src/components/Settings.tsx | 89 +++++++++++------------ 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 8944eb395..5cb44b4c1 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import { StrictMode } from "react"; +import { StrictMode, useState, useEffect } from "react"; import { App, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; @@ -8,6 +8,7 @@ const NodeTypeSettings = ({ nodeTypes, onNodeTypeChange, onAddNodeType, + onDeleteNodeType, }: { nodeTypes: Array<{ name: string; format: string }>; onNodeTypeChange: ( @@ -16,6 +17,7 @@ const NodeTypeSettings = ({ value: string, ) => Promise; onAddNodeType: () => Promise; + onDeleteNodeType: (index: number) => Promise; }) => { return (
@@ -39,6 +41,12 @@ const NodeTypeSettings = ({ } style={{ flex: 2 }} /> +
))} @@ -51,9 +59,7 @@ const NodeTypeSettings = ({ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const app = useApp(); - if (!app) { - return
An error occurred
; - } + const [nodeTypes, setNodeTypes] = useState(plugin.settings.nodeTypes || []); // Initialize nodeTypes if undefined if (!plugin.settings.nodeTypes) { @@ -73,46 +79,36 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }; const handleAddNodeType = async () => { - plugin.settings.nodeTypes.push({ - name: "", - format: "", - }); + const updatedNodeTypes = [ + ...nodeTypes, + { + name: "", + format: "", + }, + ]; + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; -); - - + // Add delete handler + const handleDeleteNodeType = async (index: number) => { + const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + }; return (

Discourse Graph Settings

- {/* Original setting */} - -
-
-
Setting #1
-
It's a secret
-
-
- { - plugin.settings.mySetting = e.target.value; - await plugin.saveSettings(); - }} - /> -
-
-
{/* Node Type Settings */} -

Settings for {app.vault.getName()}

; +

Settings for {app?.vault.getName()}

;
); }; @@ -131,20 +127,19 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); // Example obsidian settings -const obsidianSettingsEl = containerEl.createDiv(); -new Setting(obsidianSettingsEl) - .setName("Setting #1") - .setDesc("It's a secret") - .addText((text) => - text - .setPlaceholder("Enter your secret") - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }), - ); - + const obsidianSettingsEl = containerEl.createDiv(); + new Setting(obsidianSettingsEl) + .setName("Setting #1222") + .setDesc("It's a secret") + .addText((text) => + text + .setPlaceholder("Enter your secret") + .setValue(this.plugin.settings.mySetting) + .onChange(async (value) => { + this.plugin.settings.mySetting = value; + await this.plugin.saveSettings(); + }), + ); const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); From c08d23d9948bbd3ab52fa2af7983590a1ab57827 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Feb 2025 12:10:27 -0800 Subject: [PATCH 05/14] small update --- apps/obsidian/src/components/Settings.tsx | 37 ++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 5cb44b4c1..184d47b26 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -71,10 +71,14 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { field: "name" | "format", value: string, ) => { - if (!plugin.settings.nodeTypes[index]) { - plugin.settings.nodeTypes[index] = { name: "", format: "" }; + const updatedNodeTypes = [...nodeTypes]; + if (!updatedNodeTypes[index]) { + updatedNodeTypes[index] = { name: "", format: "" }; } - plugin.settings.nodeTypes[index][field] = value; + updatedNodeTypes[index][field] = value; + + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; @@ -108,7 +112,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { onAddNodeType={handleAddNodeType} onDeleteNodeType={handleDeleteNodeType} /> -

Settings for {app?.vault.getName()}

; ); }; @@ -127,19 +130,19 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); // Example obsidian settings - const obsidianSettingsEl = containerEl.createDiv(); - new Setting(obsidianSettingsEl) - .setName("Setting #1222") - .setDesc("It's a secret") - .addText((text) => - text - .setPlaceholder("Enter your secret") - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }), - ); + // const obsidianSettingsEl = containerEl.createDiv(); + // new Setting(obsidianSettingsEl) + // .setName("Setting #1222") + // .setDesc("It's a secret") + // .addText((text) => + // text + // .setPlaceholder("Enter your secret") + // .setValue(this.plugin.settings.mySetting) + // .onChange(async (value) => { + // this.plugin.settings.mySetting = value; + // await this.plugin.saveSettings(); + // }), + // ); const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); From 34f689982ea2b52d8f4a5d0926f4b9eeb23204fb Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Feb 2025 15:12:25 -0800 Subject: [PATCH 06/14] setting for hotkey --- apps/obsidian/src/components/Settings.tsx | 114 +++++++++++++++++++++- apps/obsidian/src/index.ts | 20 ++-- apps/obsidian/src/types.ts | 6 +- 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 184d47b26..2e09c0dd9 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,5 +1,5 @@ import { StrictMode, useState, useEffect } from "react"; -import { App, PluginSettingTab, Setting } from "obsidian"; +import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; @@ -57,15 +57,104 @@ const NodeTypeSettings = ({ ); }; +const HotkeyInput = ({ + value, + onChange, +}: { + value: Hotkey; + onChange: (hotkey: Hotkey) => void; +}) => { + console.log("HotkeyInput value:", value); // Debug log + + const [isListening, setIsListening] = useState(false); + const [currentHotkey, setCurrentHotkey] = useState(value); + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + + // Only proceed if at least one modifier is pressed + if (!(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey)) return; + + const modifiers: Modifier[] = []; + if (e.ctrlKey) modifiers.push("Ctrl"); + if (e.metaKey) modifiers.push("Meta"); + if (e.altKey) modifiers.push("Alt"); + if (e.shiftKey) modifiers.push("Shift"); + + const newHotkey: Hotkey = { + modifiers, + key: e.key.toUpperCase(), + }; + + setCurrentHotkey(newHotkey); + }; + + useEffect(() => { + if (isListening) { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + } + }, [isListening]); + + const formatHotkey = (hotkey: Hotkey) => { + if (!hotkey || !hotkey.modifiers) return ""; + + const formattedModifiers = hotkey.modifiers.map((mod) => { + switch (mod) { + case "Mod": + return "Cmd/Ctrl"; + case "Meta": + return "Cmd/Win"; + default: + return mod; + } + }); + + return [...formattedModifiers, hotkey.key].join(" + "); + }; + + return ( +
+ + + {isListening && ( + + )} +
+ ); +}; + const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const app = useApp(); const [nodeTypes, setNodeTypes] = useState(plugin.settings.nodeTypes || []); - + const [nodeTypeHotkey, setNodeTypeHotkey] = useState( + plugin.settings.nodeTypeHotkey, + ); // Initialize nodeTypes if undefined if (!plugin.settings.nodeTypes) { plugin.settings.nodeTypes = []; } + // Initialize nodeTypeHotkey if undefined + if (!plugin.settings.nodeTypeHotkey) { + plugin.settings.nodeTypeHotkey = { + modifiers: ["Ctrl"], + key: "\\", + }; + } + const handleNodeTypeChange = async ( index: number, field: "name" | "format", @@ -95,17 +184,34 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { await plugin.saveSettings(); }; - // Add delete handler const handleDeleteNodeType = async (index: number) => { const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); setNodeTypes(updatedNodeTypes); plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; + return (

Discourse Graph Settings

- {/* Node Type Settings */} + +
+
+
Node Type Hotkey
+
+ Click Edit and press a modifier + key combination +
+
+ { + setNodeTypeHotkey(newHotkey); + plugin.settings.nodeTypeHotkey = newHotkey; + await plugin.saveSettings(); + }} + /> +
+ Date: Mon, 24 Feb 2025 16:06:23 -0800 Subject: [PATCH 07/14] node instantiation finished --- apps/obsidian/src/components/Settings.tsx | 100 +++++++++++++++----- apps/obsidian/src/utils/registerCommands.ts | 49 +++++++++- 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 2e09c0dd9..992f906a0 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -19,34 +19,68 @@ const NodeTypeSettings = ({ onAddNodeType: () => Promise; onDeleteNodeType: (index: number) => Promise; }) => { + const [formatErrors, setFormatErrors] = useState>({}); + return (

Node Types

{nodeTypes.map((nodeType, index) => (
-
- onNodeTypeChange(index, "name", e.target.value)} - style={{ flex: 1 }} - /> - - onNodeTypeChange(index, "format", e.target.value) - } - style={{ flex: 2 }} - /> - +
+
+ + onNodeTypeChange(index, "name", e.target.value) + } + style={{ flex: 1 }} + /> + { + const value = e.target.value; + const isValid = /^\[([A-Z-]+)\]\s-\s\{content\}$/.test(value); + if (!isValid && value !== "") { + setFormatErrors((prev) => ({ + ...prev, + [index]: + "Format must be [KEYWORD] - {content} with uppercase keyword", + })); + } else { + setFormatErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[index]; + return newErrors; + }); + } + onNodeTypeChange(index, "format", value); + }} + style={{ flex: 2 }} + /> + +
+ {formatErrors[index] && ( +
+ {formatErrors[index]} +
+ )}
))} @@ -155,6 +189,11 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }; } + const validateFormat = (format: string): boolean => { + const formatRegex = /^\[([A-Z-]+)\]\s-\s\{content\}$/; + return formatRegex.test(format); + }; + const handleNodeTypeChange = async ( index: number, field: "name" | "format", @@ -164,11 +203,20 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { if (!updatedNodeTypes[index]) { updatedNodeTypes[index] = { name: "", format: "" }; } - updatedNodeTypes[index][field] = value; + updatedNodeTypes[index][field] = value; setNodeTypes(updatedNodeTypes); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); + + // Only save if format is valid or empty + if (field === "format") { + if (value === "" || validateFormat(value)) { + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + } + } else { + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + } }; const handleAddNodeType = async () => { diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 3f5bd36ab..ca4faf41e 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -1,6 +1,39 @@ -import { Editor, MarkdownFileInfo, MarkdownView } from "obsidian"; +import { + Editor, + MarkdownFileInfo, + MarkdownView, + App, + Notice, + SuggestModal, +} from "obsidian"; import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeType } from "~/types"; + +class NodeTypeModal extends SuggestModal<{ name: string; format: string }> { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNodeType[], + ) { + super(app); + } + + getSuggestions(): DiscourseNodeType[] { + return this.nodeTypes; + } + + renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNodeType) { + const selectedText = this.editor.getSelection(); + const heading = nodeType.format.split(" ")[0]; + const nodeFormat = `[[${heading} - ${selectedText}]]`; + this.editor.replaceSelection(nodeFormat); + } +} export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere @@ -44,4 +77,18 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { } }, }); + + plugin.addCommand({ + id: "open-node-type-menu", + name: "Open Node Type Menu", + hotkeys: [plugin.settings.nodeTypeHotkey], + editorCallback: (editor: Editor) => { + if (!plugin.settings.nodeTypes.length) { + new Notice("No node types configured!"); + return; + } + + new NodeTypeModal(plugin.app, editor, plugin.settings.nodeTypes).open(); + }, + }); }; From 833e2460df0a360ee0eb229807d3280b329c5d2a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 25 Feb 2025 15:20:55 -0800 Subject: [PATCH 08/14] address PR comments --- apps/obsidian/src/components/Settings.tsx | 107 +++++++++---------- apps/obsidian/src/components/SettingsTab.tsx | 3 - apps/obsidian/src/index.ts | 10 +- apps/obsidian/src/utils/registerCommands.ts | 2 +- 4 files changed, 55 insertions(+), 67 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 992f906a0..54f0f1172 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -9,6 +9,7 @@ const NodeTypeSettings = ({ onNodeTypeChange, onAddNodeType, onDeleteNodeType, + formatErrors, }: { nodeTypes: Array<{ name: string; format: string }>; onNodeTypeChange: ( @@ -18,9 +19,8 @@ const NodeTypeSettings = ({ ) => Promise; onAddNodeType: () => Promise; onDeleteNodeType: (index: number) => Promise; + formatErrors: Record; }) => { - const [formatErrors, setFormatErrors] = useState>({}); - return (

Node Types

@@ -41,26 +41,11 @@ const NodeTypeSettings = ({ /> { - const value = e.target.value; - const isValid = /^\[([A-Z-]+)\]\s-\s\{content\}$/.test(value); - if (!isValid && value !== "") { - setFormatErrors((prev) => ({ - ...prev, - [index]: - "Format must be [KEYWORD] - {content} with uppercase keyword", - })); - } else { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); - } - onNodeTypeChange(index, "format", value); - }} + onChange={(e) => + onNodeTypeChange(index, "format", e.target.value) + } style={{ flex: 2 }} />
); @@ -282,22 +297,6 @@ export class SettingsTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); - - // Example obsidian settings - // const obsidianSettingsEl = containerEl.createDiv(); - // new Setting(obsidianSettingsEl) - // .setName("Setting #1222") - // .setDesc("It's a secret") - // .addText((text) => - // text - // .setPlaceholder("Enter your secret") - // .setValue(this.plugin.settings.mySetting) - // .onChange(async (value) => { - // this.plugin.settings.mySetting = value; - // await this.plugin.saveSettings(); - // }), - // ); - const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); this.root.render( diff --git a/apps/obsidian/src/components/SettingsTab.tsx b/apps/obsidian/src/components/SettingsTab.tsx index fcd44adbb..945238b84 100644 --- a/apps/obsidian/src/components/SettingsTab.tsx +++ b/apps/obsidian/src/components/SettingsTab.tsx @@ -14,8 +14,6 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); containerEl.createEl("h2", { text: "Discourse Graph Settings" }); - - // Node Types Section containerEl.createEl("h3", { text: "Node Types" }); this.plugin.settings.nodeTypes.forEach((nodeType, index) => { @@ -41,7 +39,6 @@ export class SettingsTab extends PluginSettingTab { ); }); - // Add New Node Type Button new Setting(containerEl).addButton((btn) => btn.setButtonText("Add Node Type").onClick(async () => { this.plugin.settings.nodeTypes.push({ diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index e2492837d..867bfe43e 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -32,18 +32,10 @@ const DEFAULT_SETTINGS: Settings = { }; export default class DiscourseGraphPlugin extends Plugin { - settings: Settings = { - mySetting: "default", - nodeTypes: [], - nodeTypeHotkey: { - modifiers: ["Mod", "Shift"], - key: "Backslash", - }, - }; + settings: Settings = { ...DEFAULT_SETTINGS }; async onload() { await this.loadSettings(); - console.log("DiscourseGraphPlugin loaded"); registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); } diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index ca4faf41e..93010ad6e 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -81,7 +81,7 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({ id: "open-node-type-menu", name: "Open Node Type Menu", - hotkeys: [plugin.settings.nodeTypeHotkey], + hotkeys: [plugin.settings.nodeTypeHotkey].filter(Boolean), editorCallback: (editor: Editor) => { if (!plugin.settings.nodeTypes.length) { new Notice("No node types configured!"); From 2ee0d281cfe84f4e73d444ccc92544eb6445477e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 25 Feb 2025 15:23:45 -0800 Subject: [PATCH 09/14] add description --- apps/obsidian/src/components/Settings.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 54f0f1172..a205b48a6 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -166,7 +166,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { ); const [formatErrors, setFormatErrors] = useState>({}); - // Initialize settings if needed useEffect(() => { const initializeSettings = async () => { let needsSave = false; @@ -261,7 +260,11 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
Node Type Hotkey
- Click Edit and press a modifier + key combination +

+ This hotkey will open the node type menu to instantly create a new + node. +

+

Click Edit and press a modifier + key combination

Date: Thu, 27 Feb 2025 16:10:34 -0800 Subject: [PATCH 10/14] address PR comments --- apps/obsidian/src/components/Settings.tsx | 53 ++++++++---- apps/obsidian/src/components/SettingsTab.tsx | 53 ------------ apps/obsidian/src/index.ts | 83 +++++++++++++------ .../utils/getDiscourseNodeFormatExpression.ts | 9 ++ apps/obsidian/src/utils/registerCommands.ts | 39 --------- 5 files changed, 101 insertions(+), 136 deletions(-) delete mode 100644 apps/obsidian/src/components/SettingsTab.tsx create mode 100644 apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index a205b48a6..c5c2ceac1 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -3,6 +3,7 @@ import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; +import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; const NodeTypeSettings = ({ nodeTypes, @@ -81,7 +82,7 @@ const HotkeyInput = ({ onChange, }: { value: Hotkey; - onChange: (hotkey: Hotkey) => void; + onChange: (hotkey: Hotkey) => Promise; }) => { const [isListening, setIsListening] = useState(false); const [currentHotkey, setCurrentHotkey] = useState(value); @@ -129,6 +130,15 @@ const HotkeyInput = ({ return [...formattedModifiers, hotkey.key].join(" + "); }; + const handleSave = async () => { + try { + await onChange(currentHotkey); + setIsListening(false); + } catch (error) { + console.error("Failed to save hotkey:", error); + } + }; + return (
@@ -138,16 +148,7 @@ const HotkeyInput = ({ > {isListening ? "Listening..." : "Edit"} - {isListening && ( - - )} + {isListening && }
); }; @@ -192,8 +193,15 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }, [plugin]); const validateFormat = (format: string): boolean => { - const formatRegex = /^\[([A-Z-]+)\]\s-\s\{content\}$/; - return formatRegex.test(format); + // TODO: fix validation format + if (!format) return true; // Empty format is valid + try { + const regex = getDiscourseNodeFormatExpression(format); + // Test with a sample string to make sure it's a valid format + return regex.test("[TEST] - Sample content"); + } catch (e) { + return false; + } }; const handleNodeTypeChange = async ( @@ -215,7 +223,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { setFormatErrors((prev) => ({ ...prev, [index]: - "Format must be [KEYWORD] - {content} with uppercase keyword", + "Invalid format. You can use any {variable} in your format, e.g., [TYPE] - {content}", })); } else { setFormatErrors((prev) => { @@ -252,6 +260,17 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { await plugin.saveSettings(); }; + const handleHotkeyChange = async (newHotkey: Hotkey) => { + try { + plugin.settings.nodeTypeHotkey = newHotkey; + await plugin.saveSettings(); + setNodeTypeHotkey(newHotkey); + setNodeTypeHotkey(plugin.settings.nodeTypeHotkey); + } catch (error) { + console.error("Failed to save hotkey:", error); + } + }; + return (

Discourse Graph Settings

@@ -270,9 +289,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { { - setNodeTypeHotkey(newHotkey); - plugin.settings.nodeTypeHotkey = newHotkey; - await plugin.saveSettings(); + await handleHotkeyChange(newHotkey); }} />
@@ -310,4 +327,4 @@ export class SettingsTab extends PluginSettingTab { , ); } -} +} \ No newline at end of file diff --git a/apps/obsidian/src/components/SettingsTab.tsx b/apps/obsidian/src/components/SettingsTab.tsx deleted file mode 100644 index 945238b84..000000000 --- a/apps/obsidian/src/components/SettingsTab.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { App, PluginSettingTab, Setting } from "obsidian"; -import DiscourseGraphPlugin from "../index"; - -export class SettingsTab extends PluginSettingTab { - plugin: DiscourseGraphPlugin; - - constructor(app: App, plugin: DiscourseGraphPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - - containerEl.createEl("h2", { text: "Discourse Graph Settings" }); - containerEl.createEl("h3", { text: "Node Types" }); - - this.plugin.settings.nodeTypes.forEach((nodeType, index) => { - new Setting(containerEl) - .setName(`Node Type ${index + 1}`) - .addText((text) => - text - .setPlaceholder("Name") - .setValue(nodeType.name) - .onChange(async (value) => { - this.plugin.settings.nodeTypes[index].name = value; - await this.plugin.saveSettings(); - }), - ) - .addText((text) => - text - .setPlaceholder("Format (e.g., [[CLM]] - {content})") - .setValue(nodeType.format) - .onChange(async (value) => { - this.plugin.settings.nodeTypes[index].format = value; - await this.plugin.saveSettings(); - }), - ); - }); - - new Setting(containerEl).addButton((btn) => - btn.setButtonText("Add Node Type").onClick(async () => { - this.plugin.settings.nodeTypes.push({ - name: "", - format: "", - }); - await this.plugin.saveSettings(); - this.display(); - }), - ); - } -} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 867bfe43e..468c3fa7b 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,42 +1,72 @@ -import { Plugin } from "obsidian"; -import { registerCommands } from "~/utils/registerCommands"; +import { App, Editor, Notice, Plugin, SuggestModal } from "obsidian"; import { SettingsTab } from "~/components/Settings"; -import { Settings } from "./types"; +import { DiscourseNodeType, Settings } from "./types"; const DEFAULT_SETTINGS: Settings = { mySetting: "default", - nodeTypes: [ - { - name: "Claim", - format: "[[CLM]] - {content}", - shortcut: "C", - color: "#7DA13E", - }, - { - name: "Question", - format: "[[QUE]] - {content}", - shortcut: "Q", - color: "#99890e", - }, - { - name: "Evidence", - format: "[[EVD]] - {content}", - shortcut: "E", - color: "#DB134A", - }, - ], + nodeTypes: [], nodeTypeHotkey: { - modifiers: ["Mod", "Shift"], - key: "Backslash", + modifiers: [], + key: "", }, }; +class NodeTypeModal extends SuggestModal { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNodeType[], + ) { + super(app); + } + + getItemText(item: DiscourseNodeType): string { + return item.name; + } + + // Get all available items + getSuggestions() { + const query = this.inputEl.value.toLowerCase(); + return this.nodeTypes.filter((node) => + this.getItemText(node).toLowerCase().includes(query), + ); + } + + renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNodeType) { + const selectedText = this.editor.getSelection(); + // TODO: get the regex from the nodeType + const heading = nodeType.format.split(" ")[0]; + const nodeFormat = `[[${heading} - ${selectedText}]]`; + this.editor.replaceSelection(nodeFormat); + } +} + export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; + private registerNodeTypeCommand() { + return this.addCommand({ + id: "open-node-type-menu", + name: "Open Node Type Menu", + hotkeys: [this.settings.nodeTypeHotkey], + editorCallback: (editor: Editor) => { + if (!this.settings.nodeTypes.length) { + new Notice("No node types configured!"); + return; + } + + new NodeTypeModal(this.app, editor, this.settings.nodeTypes).open(); + }, + }); + } + async onload() { await this.loadSettings(); - registerCommands(this); + this.registerNodeTypeCommand(); this.addSettingTab(new SettingsTab(this.app, this)); } @@ -48,5 +78,6 @@ export default class DiscourseGraphPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); + await this.registerNodeTypeCommand(); } } diff --git a/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts new file mode 100644 index 000000000..ea2e96bdc --- /dev/null +++ b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts @@ -0,0 +1,9 @@ +export const getDiscourseNodeFormatExpression = (format: string) => + format + ? new RegExp( + `^${format + .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") + .replace(/{[a-zA-Z]+}/g, "(.*?)")}$`, + "s", + ) + : /$^/; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 93010ad6e..171ba6bbe 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -10,31 +10,6 @@ import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; import { DiscourseNodeType } from "~/types"; -class NodeTypeModal extends SuggestModal<{ name: string; format: string }> { - constructor( - app: App, - private editor: Editor, - private nodeTypes: DiscourseNodeType[], - ) { - super(app); - } - - getSuggestions(): DiscourseNodeType[] { - return this.nodeTypes; - } - - renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { - el.createEl("div", { text: nodeType.name }); - } - - onChooseSuggestion(nodeType: DiscourseNodeType) { - const selectedText = this.editor.getSelection(); - const heading = nodeType.format.split(" ")[0]; - const nodeFormat = `[[${heading} - ${selectedText}]]`; - this.editor.replaceSelection(nodeFormat); - } -} - export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere plugin.addCommand({ @@ -77,18 +52,4 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { } }, }); - - plugin.addCommand({ - id: "open-node-type-menu", - name: "Open Node Type Menu", - hotkeys: [plugin.settings.nodeTypeHotkey].filter(Boolean), - editorCallback: (editor: Editor) => { - if (!plugin.settings.nodeTypes.length) { - new Notice("No node types configured!"); - return; - } - - new NodeTypeModal(plugin.app, editor, plugin.settings.nodeTypes).open(); - }, - }); }; From 5eedbc4c25ddc1c69350050b1c12cfc489b008c6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 27 Feb 2025 16:48:53 -0800 Subject: [PATCH 11/14] fix the NodeType validation --- apps/obsidian/src/components/Settings.tsx | 19 ++---------- apps/obsidian/src/index.ts | 17 ++++++++--- apps/obsidian/src/utils/validateNodeFormat.ts | 29 +++++++++++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 apps/obsidian/src/utils/validateNodeFormat.ts diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index c5c2ceac1..1fc4b7203 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -3,7 +3,7 @@ import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; -import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; +import { validateNodeFormat } from "../utils/validateNodeFormat"; const NodeTypeSettings = ({ nodeTypes, @@ -192,18 +192,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { initializeSettings(); }, [plugin]); - const validateFormat = (format: string): boolean => { - // TODO: fix validation format - if (!format) return true; // Empty format is valid - try { - const regex = getDiscourseNodeFormatExpression(format); - // Test with a sample string to make sure it's a valid format - return regex.test("[TEST] - Sample content"); - } catch (e) { - return false; - } - }; - const handleNodeTypeChange = async ( index: number, field: "name" | "format", @@ -218,12 +206,11 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { setNodeTypes(updatedNodeTypes); if (field === "format") { - const isValid = value === "" || validateFormat(value); + const { isValid, error } = validateNodeFormat(value); if (!isValid) { setFormatErrors((prev) => ({ ...prev, - [index]: - "Invalid format. You can use any {variable} in your format, e.g., [TYPE] - {content}", + [index]: error ?? "Invalid format", })); } else { setFormatErrors((prev) => { diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 468c3fa7b..d66d3feef 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,6 +1,7 @@ import { App, Editor, Notice, Plugin, SuggestModal } from "obsidian"; import { SettingsTab } from "~/components/Settings"; import { DiscourseNodeType, Settings } from "./types"; +import { getDiscourseNodeFormatExpression } from "./utils/getDiscourseNodeFormatExpression"; const DEFAULT_SETTINGS: Settings = { mySetting: "default", @@ -38,10 +39,18 @@ class NodeTypeModal extends SuggestModal { onChooseSuggestion(nodeType: DiscourseNodeType) { const selectedText = this.editor.getSelection(); - // TODO: get the regex from the nodeType - const heading = nodeType.format.split(" ")[0]; - const nodeFormat = `[[${heading} - ${selectedText}]]`; - this.editor.replaceSelection(nodeFormat); + const regex = getDiscourseNodeFormatExpression(nodeType.format); + + const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); + if (!nodeFormat) return; + + const formattedNodeName = + nodeFormat[1]?.replace(/\\/g, "") + + selectedText + + nodeFormat[2]?.replace(/\\/g, ""); + if (!nodeFormat) return; + + this.editor.replaceSelection(`[[${formattedNodeName}]]`); } } diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts new file mode 100644 index 000000000..a9198b2f0 --- /dev/null +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -0,0 +1,29 @@ +export function validateNodeFormat(format: string): { + isValid: boolean; + error?: string; +} { + if (!format) { + return { + isValid: false, + error: "Format cannot be empty", + }; + } + + if (format.includes("[[") || format.includes("]]")) { + return { + isValid: false, + error: + "Format should not contain double brackets [[ or ]], these will be added automatically", + }; + } + + const hasVariable = /{[a-zA-Z]+}/.test(format); + if (!hasVariable) { + return { + isValid: false, + error: "Format must contain at least one variable in {varName} format", + }; + } + + return { isValid: true }; +} From 435233d6759f51b2ef511bed872b5b05dfa850b8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 3 Mar 2025 17:11:04 -0500 Subject: [PATCH 12/14] address PR review --- .../obsidian/src/components/NodeTypeModal.tsx | 45 +++ apps/obsidian/src/components/Settings.tsx | 272 +++++------------- apps/obsidian/src/index.ts | 72 +---- apps/obsidian/src/types.ts | 4 - apps/obsidian/src/utils/registerCommands.ts | 11 +- 5 files changed, 125 insertions(+), 279 deletions(-) create mode 100644 apps/obsidian/src/components/NodeTypeModal.tsx diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx new file mode 100644 index 000000000..5d20752fd --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -0,0 +1,45 @@ +import { App, Editor, SuggestModal } from "obsidian"; +import { DiscourseNodeType } from "../types"; +import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; + +export class NodeTypeModal extends SuggestModal { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNodeType[], + ) { + super(app); + } + + getItemText(item: DiscourseNodeType): string { + return item.name; + } + + // Get all available items + getSuggestions() { + const query = this.inputEl.value.toLowerCase(); + return this.nodeTypes.filter((node) => + this.getItemText(node).toLowerCase().includes(query), + ); + } + + renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNodeType) { + const selectedText = this.editor.getSelection(); + const regex = getDiscourseNodeFormatExpression(nodeType.format); + + const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); + if (!nodeFormat) return; + + const formattedNodeName = + nodeFormat[1]?.replace(/\\/g, "") + + selectedText + + nodeFormat[2]?.replace(/\\/g, ""); + if (!nodeFormat) return; + + this.editor.replaceSelection(`[[${formattedNodeName}]]`); + } +} diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 1fc4b7203..dbf2a2753 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,170 +1,14 @@ import { StrictMode, useState, useEffect } from "react"; -import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; +import { App, PluginSettingTab } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; import { validateNodeFormat } from "../utils/validateNodeFormat"; -const NodeTypeSettings = ({ - nodeTypes, - onNodeTypeChange, - onAddNodeType, - onDeleteNodeType, - formatErrors, -}: { - nodeTypes: Array<{ name: string; format: string }>; - onNodeTypeChange: ( - index: number, - field: "name" | "format", - value: string, - ) => Promise; - onAddNodeType: () => Promise; - onDeleteNodeType: (index: number) => Promise; - formatErrors: Record; -}) => { - return ( -
-

Node Types

- {nodeTypes.map((nodeType, index) => ( -
-
-
- - onNodeTypeChange(index, "name", e.target.value) - } - style={{ flex: 1 }} - /> - - onNodeTypeChange(index, "format", e.target.value) - } - style={{ flex: 2 }} - /> - -
- {formatErrors[index] && ( -
- {formatErrors[index]} -
- )} -
-
- ))} -
- -
-
- ); -}; - -const HotkeyInput = ({ - value, - onChange, -}: { - value: Hotkey; - onChange: (hotkey: Hotkey) => Promise; -}) => { - const [isListening, setIsListening] = useState(false); - const [currentHotkey, setCurrentHotkey] = useState(value); - - const handleKeyDown = (e: KeyboardEvent) => { - e.preventDefault(); - - if (!(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey)) return; - - const modifiers: Modifier[] = []; - if (e.ctrlKey) modifiers.push("Ctrl"); - if (e.metaKey) modifiers.push("Meta"); - if (e.altKey) modifiers.push("Alt"); - if (e.shiftKey) modifiers.push("Shift"); - - const newHotkey: Hotkey = { - modifiers, - key: e.key.toUpperCase(), - }; - - setCurrentHotkey(newHotkey); - }; - - useEffect(() => { - if (isListening) { - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - } - }, [isListening]); - - const formatHotkey = (hotkey: Hotkey) => { - if (!hotkey || !hotkey.modifiers) return ""; - - const formattedModifiers = hotkey.modifiers.map((mod) => { - switch (mod) { - case "Mod": - return "Cmd/Ctrl"; - case "Meta": - return "Cmd/Win"; - default: - return mod; - } - }); - - return [...formattedModifiers, hotkey.key].join(" + "); - }; - - const handleSave = async () => { - try { - await onChange(currentHotkey); - setIsListening(false); - } catch (error) { - console.error("Failed to save hotkey:", error); - } - }; - - return ( -
- - - {isListening && } -
- ); -}; - -const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { - const app = useApp(); +const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const [nodeTypes, setNodeTypes] = useState( () => plugin.settings.nodeTypes ?? [], ); - const [nodeTypeHotkey, setNodeTypeHotkey] = useState( - () => - plugin.settings.nodeTypeHotkey ?? { - modifiers: ["Ctrl"], - key: "\\", - }, - ); const [formatErrors, setFormatErrors] = useState>({}); useEffect(() => { @@ -176,14 +20,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { needsSave = true; } - if (!plugin.settings.nodeTypeHotkey) { - plugin.settings.nodeTypeHotkey = { - modifiers: ["Ctrl"], - key: "\\", - }; - needsSave = true; - } - if (needsSave) { await plugin.saveSettings(); } @@ -196,7 +32,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { index: number, field: "name" | "format", value: string, - ) => { + ): Promise => { const updatedNodeTypes = [...nodeTypes]; if (!updatedNodeTypes[index]) { updatedNodeTypes[index] = { name: "", format: "" }; @@ -227,7 +63,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { } }; - const handleAddNodeType = async () => { + const handleAddNodeType = async (): Promise => { const updatedNodeTypes = [ ...nodeTypes, { @@ -240,54 +76,71 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { await plugin.saveSettings(); }; - const handleDeleteNodeType = async (index: number) => { + const handleDeleteNodeType = async (index: number): Promise => { const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); setNodeTypes(updatedNodeTypes); plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; - - const handleHotkeyChange = async (newHotkey: Hotkey) => { - try { - plugin.settings.nodeTypeHotkey = newHotkey; - await plugin.saveSettings(); - setNodeTypeHotkey(newHotkey); - setNodeTypeHotkey(plugin.settings.nodeTypeHotkey); - } catch (error) { - console.error("Failed to save hotkey:", error); - } - }; - return ( -
-

Discourse Graph Settings

- -
-
-
Node Type Hotkey
-
-

- This hotkey will open the node type menu to instantly create a new - node. -

-

Click Edit and press a modifier + key combination

+
+

Node Types

+ {nodeTypes.map((nodeType, index) => ( +
+
+
+ + handleNodeTypeChange(index, "name", e.target.value) + } + style={{ flex: 1 }} + /> + + handleNodeTypeChange(index, "format", e.target.value) + } + style={{ flex: 2 }} + /> + +
+ {formatErrors[index] && ( +
+ {formatErrors[index]} +
+ )}
- { - await handleHotkeyChange(newHotkey); - }} - /> + ))} +
+
+
+ ); +}; - +const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { + return ( +
+
); }; @@ -314,4 +167,11 @@ export class SettingsTab extends PluginSettingTab { , ); } + + hide(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } } \ No newline at end of file diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index d66d3feef..ca0598fbd 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,81 +1,18 @@ -import { App, Editor, Notice, Plugin, SuggestModal } from "obsidian"; +import { Plugin } from "obsidian"; import { SettingsTab } from "~/components/Settings"; -import { DiscourseNodeType, Settings } from "./types"; -import { getDiscourseNodeFormatExpression } from "./utils/getDiscourseNodeFormatExpression"; +import { Settings } from "./types"; +import { registerCommands } from "./utils/registerCommands"; const DEFAULT_SETTINGS: Settings = { - mySetting: "default", nodeTypes: [], - nodeTypeHotkey: { - modifiers: [], - key: "", - }, }; -class NodeTypeModal extends SuggestModal { - constructor( - app: App, - private editor: Editor, - private nodeTypes: DiscourseNodeType[], - ) { - super(app); - } - - getItemText(item: DiscourseNodeType): string { - return item.name; - } - - // Get all available items - getSuggestions() { - const query = this.inputEl.value.toLowerCase(); - return this.nodeTypes.filter((node) => - this.getItemText(node).toLowerCase().includes(query), - ); - } - - renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { - el.createEl("div", { text: nodeType.name }); - } - - onChooseSuggestion(nodeType: DiscourseNodeType) { - const selectedText = this.editor.getSelection(); - const regex = getDiscourseNodeFormatExpression(nodeType.format); - - const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); - if (!nodeFormat) return; - - const formattedNodeName = - nodeFormat[1]?.replace(/\\/g, "") + - selectedText + - nodeFormat[2]?.replace(/\\/g, ""); - if (!nodeFormat) return; - - this.editor.replaceSelection(`[[${formattedNodeName}]]`); - } -} - export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; - private registerNodeTypeCommand() { - return this.addCommand({ - id: "open-node-type-menu", - name: "Open Node Type Menu", - hotkeys: [this.settings.nodeTypeHotkey], - editorCallback: (editor: Editor) => { - if (!this.settings.nodeTypes.length) { - new Notice("No node types configured!"); - return; - } - - new NodeTypeModal(this.app, editor, this.settings.nodeTypes).open(); - }, - }); - } - async onload() { await this.loadSettings(); - this.registerNodeTypeCommand(); + registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); } @@ -87,6 +24,5 @@ export default class DiscourseGraphPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); - await this.registerNodeTypeCommand(); } } diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index d98c8efb2..208afc132 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -1,5 +1,3 @@ -import { Hotkey } from "obsidian"; - export type DiscourseNodeType = { name: string; format: string; @@ -8,7 +6,5 @@ export type DiscourseNodeType = { }; export type Settings = { - mySetting: string; nodeTypes: DiscourseNodeType[]; - nodeTypeHotkey: Hotkey; }; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 171ba6bbe..b4adb7d7c 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -8,7 +8,7 @@ import { } from "obsidian"; import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; -import { DiscourseNodeType } from "~/types"; +import { NodeTypeModal } from "~/components/NodeTypeModal"; export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere @@ -52,4 +52,13 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { } }, }); + + plugin.addCommand({ + id: "open-node-type-menu", + name: "Open Node Type Menu", + hotkeys: [{ modifiers: ["Mod"], key: "\\" }], + editorCallback: (editor: Editor) => { + new NodeTypeModal(plugin.app, editor, plugin.settings.nodeTypes).open(); + }, + }); }; From d9dc4337977f7d2ad5fa5217898162045c0504b5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 3 Mar 2025 17:37:55 -0500 Subject: [PATCH 13/14] add Save button for new changes --- .../obsidian/src/components/NodeTypeModal.tsx | 1 - apps/obsidian/src/components/Settings.tsx | 53 +++++++++++++++---- apps/obsidian/src/utils/registerCommands.ts | 1 - apps/obsidian/src/utils/validateNodeFormat.ts | 3 +- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index 5d20752fd..495f2aac7 100644 --- a/apps/obsidian/src/components/NodeTypeModal.tsx +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -15,7 +15,6 @@ export class NodeTypeModal extends SuggestModal { return item.name; } - // Get all available items getSuggestions() { const query = this.inputEl.value.toLowerCase(); return this.nodeTypes.filter((node) => diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index dbf2a2753..89d3af77a 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -10,6 +10,7 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { () => plugin.settings.nodeTypes ?? [], ); const [formatErrors, setFormatErrors] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); useEffect(() => { const initializeSettings = async () => { @@ -40,6 +41,7 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { updatedNodeTypes[index][field] = value; setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); if (field === "format") { const { isValid, error } = validateNodeFormat(value); @@ -54,16 +56,11 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { delete newErrors[index]; return newErrors; }); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); } - } else { - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); } }; - const handleAddNodeType = async (): Promise => { + const handleAddNodeType = (): void => { const updatedNodeTypes = [ ...nodeTypes, { @@ -72,8 +69,7 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }, ]; setNodeTypes(updatedNodeTypes); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); + setHasUnsavedChanges(true); }; const handleDeleteNodeType = async (index: number): Promise => { @@ -82,6 +78,29 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; + + const handleSave = async (): Promise => { + let hasErrors = false; + for (let i = 0; i < nodeTypes.length; i++) { + const { isValid, error } = validateNodeFormat(nodeTypes[i]?.format ?? ""); + if (!isValid) { + setFormatErrors((prev) => ({ + ...prev, + [i]: error ?? "Invalid format", + })); + hasErrors = true; + } + } + + if (hasErrors) { + return; + } + + plugin.settings.nodeTypes = nodeTypes; + await plugin.saveSettings(); + setHasUnsavedChanges(false); + }; + return (

Node Types

@@ -131,8 +150,24 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
))}
- +
+ + +
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )}
); }; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index b4adb7d7c..5aa566dee 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -30,7 +30,6 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); - console.log(plugin.settings); // This adds a complex command that can check whether the current state of the app allows execution of the command plugin.addCommand({ diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts index a9198b2f0..da73e4afd 100644 --- a/apps/obsidian/src/utils/validateNodeFormat.ts +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -12,8 +12,7 @@ export function validateNodeFormat(format: string): { if (format.includes("[[") || format.includes("]]")) { return { isValid: false, - error: - "Format should not contain double brackets [[ or ]], these will be added automatically", + error: "Format should not contain double brackets [[ or ]]", }; } From ea794c137cab5f1768a7815dd7a8d18824cb9925 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 3 Mar 2025 19:55:59 -0500 Subject: [PATCH 14/14] rename to match the naming convention in roam --- apps/obsidian/src/components/NodeTypeModal.tsx | 12 ++++++------ apps/obsidian/src/types.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index 495f2aac7..baa9fdfc8 100644 --- a/apps/obsidian/src/components/NodeTypeModal.tsx +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -1,17 +1,17 @@ import { App, Editor, SuggestModal } from "obsidian"; -import { DiscourseNodeType } from "../types"; +import { DiscourseNode } from "../types"; import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; -export class NodeTypeModal extends SuggestModal { +export class NodeTypeModal extends SuggestModal { constructor( app: App, private editor: Editor, - private nodeTypes: DiscourseNodeType[], + private nodeTypes: DiscourseNode[], ) { super(app); } - getItemText(item: DiscourseNodeType): string { + getItemText(item: DiscourseNode): string { return item.name; } @@ -22,11 +22,11 @@ export class NodeTypeModal extends SuggestModal { ); } - renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + renderSuggestion(nodeType: DiscourseNode, el: HTMLElement) { el.createEl("div", { text: nodeType.name }); } - onChooseSuggestion(nodeType: DiscourseNodeType) { + onChooseSuggestion(nodeType: DiscourseNode) { const selectedText = this.editor.getSelection(); const regex = getDiscourseNodeFormatExpression(nodeType.format); diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index 208afc132..ff01b00e3 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -1,4 +1,4 @@ -export type DiscourseNodeType = { +export type DiscourseNode = { name: string; format: string; shortcut?: string; @@ -6,5 +6,5 @@ export type DiscourseNodeType = { }; export type Settings = { - nodeTypes: DiscourseNodeType[]; + nodeTypes: DiscourseNode[]; };