diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx new file mode 100644 index 000000000..baa9fdfc8 --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -0,0 +1,44 @@ +import { App, Editor, SuggestModal } from "obsidian"; +import { DiscourseNode } from "../types"; +import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; + +export class NodeTypeModal extends SuggestModal { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNode[], + ) { + super(app); + } + + getItemText(item: DiscourseNode): string { + return item.name; + } + + getSuggestions() { + const query = this.inputEl.value.toLowerCase(); + return this.nodeTypes.filter((node) => + this.getItemText(node).toLowerCase().includes(query), + ); + } + + renderSuggestion(nodeType: DiscourseNode, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNode) { + 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 cfe4d098c..89d3af77a 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,16 +1,183 @@ -import { StrictMode } from "react"; -import { App, PluginSettingTab, Setting } from "obsidian"; +import { StrictMode, useState, useEffect } from "react"; +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 Settings = () => { - const app = useApp(); - if (!app) { - return
An error occured
; - } +const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { + const [nodeTypes, setNodeTypes] = useState( + () => plugin.settings.nodeTypes ?? [], + ); + const [formatErrors, setFormatErrors] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + useEffect(() => { + const initializeSettings = async () => { + let needsSave = false; + + if (!plugin.settings.nodeTypes) { + plugin.settings.nodeTypes = []; + needsSave = true; + } + + if (needsSave) { + await plugin.saveSettings(); + } + }; + + initializeSettings(); + }, [plugin]); + + const handleNodeTypeChange = async ( + index: number, + field: "name" | "format", + value: string, + ): Promise => { + const updatedNodeTypes = [...nodeTypes]; + if (!updatedNodeTypes[index]) { + updatedNodeTypes[index] = { name: "", format: "" }; + } + + updatedNodeTypes[index][field] = value; + setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); + + if (field === "format") { + const { isValid, error } = validateNodeFormat(value); + if (!isValid) { + setFormatErrors((prev) => ({ + ...prev, + [index]: error ?? "Invalid format", + })); + } else { + setFormatErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[index]; + return newErrors; + }); + } + } + }; + + const handleAddNodeType = (): void => { + const updatedNodeTypes = [ + ...nodeTypes, + { + name: "", + format: "", + }, + ]; + setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); + }; - return

Settings for {app.vault.getName()}

; + const handleDeleteNodeType = async (index: number): Promise => { + const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); + setNodeTypes(updatedNodeTypes); + 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

+ {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]} +
+ )} +
+
+ ))} +
+
+ + +
+
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )} +
+ ); +}; + +const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { + return ( +
+ +
+ ); }; export class SettingsTab extends PluginSettingTab { @@ -25,31 +192,21 @@ export class SettingsTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); - - // Example react component in settings 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(); - }), - ); + 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 021a7d356..ca0598fbd 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,17 +1,14 @@ import { Plugin } from "obsidian"; -import { registerCommands } from "~/utils/registerCommands"; import { SettingsTab } from "~/components/Settings"; - -type Settings = { - mySetting: string; -}; +import { Settings } from "./types"; +import { registerCommands } from "./utils/registerCommands"; const DEFAULT_SETTINGS: Settings = { - mySetting: "default", + nodeTypes: [], }; export default class DiscourseGraphPlugin extends Plugin { - settings: Settings = { mySetting: "default" }; + settings: Settings = { ...DEFAULT_SETTINGS }; async onload() { await this.loadSettings(); diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts new file mode 100644 index 000000000..ff01b00e3 --- /dev/null +++ b/apps/obsidian/src/types.ts @@ -0,0 +1,10 @@ +export type DiscourseNode = { + name: string; + format: string; + shortcut?: string; + color?: string; +}; + +export type Settings = { + nodeTypes: DiscourseNode[]; +}; 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 f2235bf3e..5aa566dee 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -1,6 +1,14 @@ -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 { NodeTypeModal } from "~/components/NodeTypeModal"; export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere @@ -22,6 +30,7 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + // 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", @@ -42,4 +51,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(); + }, + }); }; diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts new file mode 100644 index 000000000..da73e4afd --- /dev/null +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -0,0 +1,28 @@ +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 ]]", + }; + } + + 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 }; +}