diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index 6e91d65d1..793ffc3b5 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,11 @@ 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({ + app: this.app, + editor: this.editor, + nodeType, + }); } } diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 9931375ec..f709e949b 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,9 +1,10 @@ -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 { processTextToDiscourseNode } from "./utils/createNodeFromSelectedText"; const DEFAULT_SETTINGS: Settings = { nodeTypes: [], @@ -27,6 +28,36 @@ 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({ + app: 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..4e5174d87 --- /dev/null +++ b/apps/obsidian/src/utils/createNodeFromSelectedText.ts @@ -0,0 +1,102 @@ +import { App, Editor, Notice, TFile } from "obsidian"; +import { DiscourseNode } from "~/types"; +import { getDiscourseNodeFormatExpression } from "./getDiscourseNodeFormatExpression"; +import { checkInvalidChars } from "./validateNodeType"; + +export const 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 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 existingFile; + } + + const newFile = await app.vault.create(`${formattedNodeName}.md`, ""); + await app.fileManager.processFrontMatter(newFile, (fm) => { + fm.nodeTypeId = nodeType.id; + }); + + 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); + new Notice( + `Error creating node: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + 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; + + 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; +};