From 0af2d2977a0708ef98d5d875f5ca86c1db361a09 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 9 May 2025 13:08:56 -0400 Subject: [PATCH 1/4] create node in right-click menu --- .../obsidian/src/components/NodeTypeModal.tsx | 56 +------------ apps/obsidian/src/index.ts | 33 +++++++- .../src/utils/createNodeFromSelectedText.ts | 79 +++++++++++++++++++ 3 files changed, 112 insertions(+), 56 deletions(-) create mode 100644 apps/obsidian/src/utils/createNodeFromSelectedText.ts diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index 6e91d65d1..f3cd64961 100644 --- a/apps/obsidian/src/components/NodeTypeModal.tsx +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -1,7 +1,6 @@ import { App, Editor, SuggestModal, TFile, Notice } from "obsidian"; import { DiscourseNode } from "~/types"; -import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; -import { checkInvalidChars } from "~/utils/validateNodeType"; +import { processTextToDiscourseNode } from "~/utils/createNodeFromSelectedText"; export class NodeTypeModal extends SuggestModal { constructor( @@ -27,58 +26,7 @@ export class NodeTypeModal extends SuggestModal { el.createEl("div", { text: nodeType.name }); } - async createDiscourseNode( - title: string, - nodeType: DiscourseNode, - ): Promise { - try { - const instanceId = `${nodeType.id}-${Date.now()}`; - const filename = `${title}.md`; - - await this.app.vault.create(filename, ""); - - const newFile = this.app.vault.getAbstractFileByPath(filename); - if (!(newFile instanceof TFile)) { - throw new Error("Failed to create new file"); - } - - await this.app.fileManager.processFrontMatter(newFile, (fm) => { - fm.nodeTypeId = nodeType.id; - fm.nodeInstanceId = instanceId; - }); - - new Notice(`Created discourse node: ${title}`); - return newFile; - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - new Notice(`Error creating discourse node: ${errorMessage}`, 5000); - console.error("Failed to create discourse node:", error); - return null; - } - } - async 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, ""); - - const isFilenameValid = checkInvalidChars(formattedNodeName); - if (!isFilenameValid.isValid) { - new Notice(`${isFilenameValid.error}`, 5000); - return; - } - - const newFile = await this.createDiscourseNode(formattedNodeName, nodeType); - if (newFile) { - this.editor.replaceSelection(`[[${formattedNodeName}]]`); - } + await processTextToDiscourseNode(this.app, this.editor, nodeType); } } diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 9931375ec..17b48f47e 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,9 +1,12 @@ -import { Plugin } from "obsidian"; +import { Plugin, Editor, Menu, Notice, TFile } from "obsidian"; import { SettingsTab } from "~/components/Settings"; import { Settings } from "~/types"; import { registerCommands } from "~/utils/registerCommands"; import { DiscourseContextView } from "~/components/DiscourseContextView"; -import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; +import { VIEW_TYPE_DISCOURSE_CONTEXT, DiscourseNode } from "~/types"; +import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; +import { checkInvalidChars } from "~/utils/validateNodeType"; +import { processTextToDiscourseNode } from "./utils/createNodeFromSelectedText"; const DEFAULT_SETTINGS: Settings = { nodeTypes: [], @@ -27,6 +30,32 @@ export default class DiscourseGraphPlugin extends Plugin { this.addRibbonIcon("telescope", "Toggle Discourse Context", () => { this.toggleDiscourseContextView(); }); + + this.registerEvent( + this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor) => { + if (!editor.getSelection()) return; + + menu.addItem((menuItem) => { + menuItem.setTitle("Turn into Discourse Node"); + menuItem.setIcon("file-type"); + + // Create submenu using the unofficial API pattern + // @ts-ignore - setSubmenu is not officially in the API but works + const submenu = menuItem.setSubmenu(); + + this.settings.nodeTypes.forEach((nodeType) => { + submenu.addItem((item: any) => { + item + .setTitle(nodeType.name) + .setIcon("file-type") + .onClick(async () => { + await processTextToDiscourseNode(this.app, editor, nodeType); + }); + }); + }); + }); + }), + ); } toggleDiscourseContextView() { diff --git a/apps/obsidian/src/utils/createNodeFromSelectedText.ts b/apps/obsidian/src/utils/createNodeFromSelectedText.ts new file mode 100644 index 000000000..988f8f70d --- /dev/null +++ b/apps/obsidian/src/utils/createNodeFromSelectedText.ts @@ -0,0 +1,79 @@ +import { App, Editor, Notice, TFile } from "obsidian"; +import { DiscourseNode } from "~/types"; +import { getDiscourseNodeFormatExpression } from "./getDiscourseNodeFormatExpression"; +import { checkInvalidChars } from "./validateNodeType"; + +export function formatNodeName( + selectedText: string, + nodeType: DiscourseNode, +): string | null { + const regex = getDiscourseNodeFormatExpression(nodeType.format); + const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); + + if (!nodeFormat) return null; + + return ( + nodeFormat[1]?.replace(/\\/g, "") + + selectedText + + nodeFormat[2]?.replace(/\\/g, "") + ); +} + +export async function createDiscourseNodeFile( + app: App, + formattedNodeName: string, + nodeType: DiscourseNode, +): Promise { + try { + const existingFile = app.vault.getAbstractFileByPath( + `${formattedNodeName}.md`, + ); + if (existingFile && existingFile instanceof TFile) { + new Notice(`File ${formattedNodeName} already exists`, 3000); + return existingFile; + } + + const newFile = await app.vault.create(`${formattedNodeName}.md`, ""); + await app.fileManager.processFrontMatter(newFile, (fm) => { + fm.nodeTypeId = nodeType.id; + }); + + new Notice(`Created discourse node: ${formattedNodeName}`); + return newFile; + } catch (error) { + console.error("Error creating discourse node:", error); + new Notice( + `Error creating node: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + return null; + } +} +export async function processTextToDiscourseNode( + app: any, + editor: Editor, + nodeType: DiscourseNode, +): Promise { + const selectedText = editor.getSelection(); + if (!selectedText) return null; + + const formattedNodeName = formatNodeName(selectedText, nodeType); + if (!formattedNodeName) return null; + + const isFilenameValid = checkInvalidChars(formattedNodeName); + if (!isFilenameValid.isValid) { + new Notice(`${isFilenameValid.error}`, 5000); + return null; + } + + const newFile = await createDiscourseNodeFile( + app, + formattedNodeName, + nodeType, + ); + if (newFile) { + editor.replaceSelection(`[[${formattedNodeName}]]`); + } + + return newFile; +} From a2b4609bea69a66c8a70da458b687a2f113125f0 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 9 May 2025 13:15:44 -0400 Subject: [PATCH 2/4] small fix --- apps/obsidian/src/utils/createNodeFromSelectedText.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/createNodeFromSelectedText.ts b/apps/obsidian/src/utils/createNodeFromSelectedText.ts index 988f8f70d..8c8a8806d 100644 --- a/apps/obsidian/src/utils/createNodeFromSelectedText.ts +++ b/apps/obsidian/src/utils/createNodeFromSelectedText.ts @@ -30,7 +30,7 @@ export async function createDiscourseNodeFile( ); if (existingFile && existingFile instanceof TFile) { new Notice(`File ${formattedNodeName} already exists`, 3000); - return existingFile; + return null; } const newFile = await app.vault.create(`${formattedNodeName}.md`, ""); From 1751e50ff22721a0e74f5adb457889e621e50ae1 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 9 May 2025 13:18:33 -0400 Subject: [PATCH 3/4] address PR comments --- apps/obsidian/src/index.ts | 2 -- apps/obsidian/src/utils/createNodeFromSelectedText.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 17b48f47e..333f4170d 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -4,8 +4,6 @@ import { Settings } from "~/types"; import { registerCommands } from "~/utils/registerCommands"; import { DiscourseContextView } from "~/components/DiscourseContextView"; import { VIEW_TYPE_DISCOURSE_CONTEXT, DiscourseNode } from "~/types"; -import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; -import { checkInvalidChars } from "~/utils/validateNodeType"; import { processTextToDiscourseNode } from "./utils/createNodeFromSelectedText"; const DEFAULT_SETTINGS: Settings = { diff --git a/apps/obsidian/src/utils/createNodeFromSelectedText.ts b/apps/obsidian/src/utils/createNodeFromSelectedText.ts index 8c8a8806d..bdfcaf852 100644 --- a/apps/obsidian/src/utils/createNodeFromSelectedText.ts +++ b/apps/obsidian/src/utils/createNodeFromSelectedText.ts @@ -50,7 +50,7 @@ export async function createDiscourseNodeFile( } } export async function processTextToDiscourseNode( - app: any, + app: App, editor: Editor, nodeType: DiscourseNode, ): Promise { From d80214b531d8f6b7b84a379732801983bfe43de2 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 12 May 2025 12:18:04 -0400 Subject: [PATCH 4/4] address PR comments --- .../obsidian/src/components/NodeTypeModal.tsx | 6 +- apps/obsidian/src/index.ts | 6 +- .../src/utils/createNodeFromSelectedText.ts | 65 +++++++++++++------ 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index f3cd64961..793ffc3b5 100644 --- a/apps/obsidian/src/components/NodeTypeModal.tsx +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -27,6 +27,10 @@ export class NodeTypeModal extends SuggestModal { } async onChooseSuggestion(nodeType: DiscourseNode) { - await processTextToDiscourseNode(this.app, this.editor, nodeType); + await processTextToDiscourseNode({ + app: this.app, + editor: this.editor, + nodeType, + }); } } diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 333f4170d..f709e949b 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -47,7 +47,11 @@ export default class DiscourseGraphPlugin extends Plugin { .setTitle(nodeType.name) .setIcon("file-type") .onClick(async () => { - await processTextToDiscourseNode(this.app, editor, nodeType); + await processTextToDiscourseNode({ + app: this.app, + editor, + nodeType, + }); }); }); }); diff --git a/apps/obsidian/src/utils/createNodeFromSelectedText.ts b/apps/obsidian/src/utils/createNodeFromSelectedText.ts index bdfcaf852..4e5174d87 100644 --- a/apps/obsidian/src/utils/createNodeFromSelectedText.ts +++ b/apps/obsidian/src/utils/createNodeFromSelectedText.ts @@ -3,10 +3,10 @@ import { DiscourseNode } from "~/types"; import { getDiscourseNodeFormatExpression } from "./getDiscourseNodeFormatExpression"; import { checkInvalidChars } from "./validateNodeType"; -export function formatNodeName( +export const formatNodeName = ( selectedText: string, nodeType: DiscourseNode, -): string | null { +): string | null => { const regex = getDiscourseNodeFormatExpression(nodeType.format); const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); @@ -17,20 +17,24 @@ export function formatNodeName( selectedText + nodeFormat[2]?.replace(/\\/g, "") ); -} +}; -export async function createDiscourseNodeFile( - app: App, - formattedNodeName: string, - nodeType: DiscourseNode, -): Promise { +export const createDiscourseNodeFile = async ({ + app, + formattedNodeName, + nodeType, +}: { + app: App; + formattedNodeName: string; + nodeType: DiscourseNode; +}): Promise => { try { const existingFile = app.vault.getAbstractFileByPath( `${formattedNodeName}.md`, ); if (existingFile && existingFile instanceof TFile) { new Notice(`File ${formattedNodeName} already exists`, 3000); - return null; + return existingFile; } const newFile = await app.vault.create(`${formattedNodeName}.md`, ""); @@ -38,7 +42,23 @@ export async function createDiscourseNodeFile( fm.nodeTypeId = nodeType.id; }); - new Notice(`Created discourse node: ${formattedNodeName}`); + const notice = new DocumentFragment(); + const spanEl = notice.createEl("span", { + text: "Created discourse node: ", + }); + + const linkEl = spanEl.createEl("a", { + text: formattedNodeName, + cls: "clickable-link", + }); + linkEl.style.textDecoration = "underline"; + linkEl.style.cursor = "pointer"; + linkEl.addEventListener("click", () => { + app.workspace.openLinkText(formattedNodeName, "", false); + }); + + new Notice(notice, 4000); + return newFile; } catch (error) { console.error("Error creating discourse node:", error); @@ -48,15 +68,18 @@ export async function createDiscourseNodeFile( ); return null; } -} -export async function processTextToDiscourseNode( - app: App, - editor: Editor, - nodeType: DiscourseNode, -): Promise { - const selectedText = editor.getSelection(); - if (!selectedText) return null; +}; +export const processTextToDiscourseNode = async ({ + app, + editor, + nodeType, +}: { + app: App; + editor: Editor; + nodeType: DiscourseNode; +}): Promise => { + const selectedText = editor.getSelection(); const formattedNodeName = formatNodeName(selectedText, nodeType); if (!formattedNodeName) return null; @@ -66,14 +89,14 @@ export async function processTextToDiscourseNode( return null; } - const newFile = await createDiscourseNodeFile( + const newFile = await createDiscourseNodeFile({ app, formattedNodeName, nodeType, - ); + }); if (newFile) { editor.replaceSelection(`[[${formattedNodeName}]]`); } return newFile; -} +};