From b309a81231637412129659e390008d5a88dfbd1f Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 16 Aug 2026 21:53:43 -0600 Subject: [PATCH 1/5] Open node creation dialog for highlighted text --- .../roam/src/components/DiscourseNodeMenu.tsx | 22 ++++++ .../openCreateNodeDialogFromSelection.test.ts | 75 +++++++++++++++++++ .../openCreateNodeDialogFromSelection.ts | 43 +++++++++++ 3 files changed, 140 insertions(+) create mode 100644 apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts create mode 100644 apps/roam/src/utils/openCreateNodeDialogFromSelection.ts diff --git a/apps/roam/src/components/DiscourseNodeMenu.tsx b/apps/roam/src/components/DiscourseNodeMenu.tsx index 3b6f6e1b9..9378e2d40 100644 --- a/apps/roam/src/components/DiscourseNodeMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeMenu.tsx @@ -33,6 +33,7 @@ import { } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; import type { PersonalSettings } from "~/components/settings/utils/zodSchema"; +import { openCreateNodeDialogFromSelection } from "~/utils/openCreateNodeDialogFromSelection"; type Props = { textarea?: HTMLTextAreaElement; @@ -125,6 +126,27 @@ const NodeMenu = ({ // https://github.com/RoamJS/query-builder/issues/286 if (document.activeElement === textarea) document.body.click(); + if (textarea && selectionStart !== selectionEnd) { + const { windowId } = getUids(textarea); + onClose(); + openCreateNodeDialogFromSelection({ + blockUid: targetBlockUid, + extensionAPI, + nodeType: nodeUid, + onInserted: (pageTitle) => { + posthog.capture("Discourse Node: Created via Node Menu", { + nodeType: nodeUid, + text: pageTitle, + }); + }, + selectedText: highlighted, + selectionEnd, + selectionStart, + windowId: windowId || "main-window", + }); + return; + } + const createNodeAndUpdateBlock = async () => { const pageName = await getNewDiscourseNodeText({ text: highlighted, diff --git a/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts b/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts new file mode 100644 index 000000000..e7577270d --- /dev/null +++ b/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OnloadArgs } from "roamjs-components/types"; +import type { ModifyNodeDialogProps } from "~/components/ModifyNodeDialog"; + +type InsertPageRefAtRangeArgs = { + blockUid: string; + pageTitle: string; + selectionEnd: number; + selectionStart: number; + windowId: string; +}; + +const mocks = vi.hoisted(() => ({ + insertPageRefAtRange: + vi.fn<(args: InsertPageRefAtRangeArgs) => Promise>(), + renderModifyNodeDialog: vi.fn<(props: ModifyNodeDialogProps) => void>(), +})); + +vi.mock("~/components/ModifyNodeDialog", () => ({ + renderModifyNodeDialog: mocks.renderModifyNodeDialog, +})); + +vi.mock("~/utils/advancedSearchFooterUtils", () => ({ + insertPageRefAtRange: mocks.insertPageRefAtRange, +})); + +import { openCreateNodeDialogFromSelection } from "~/utils/openCreateNodeDialogFromSelection"; + +describe("openCreateNodeDialogFromSelection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.insertPageRefAtRange.mockResolvedValue(undefined); + }); + + it("prefills the dialog and replaces the selected text after creation", async () => { + const extensionAPI = {} as OnloadArgs["extensionAPI"]; + const onInserted = vi.fn(); + + openCreateNodeDialogFromSelection({ + blockUid: "block-uid", + extensionAPI, + nodeType: "node-type-uid", + onInserted, + selectedText: "highlighted text", + selectionEnd: 20, + selectionStart: 4, + windowId: "main-window", + }); + + expect(mocks.renderModifyNodeDialog).toHaveBeenCalledWith( + expect.objectContaining({ + extensionAPI, + initialValue: { text: "highlighted text", uid: "" }, + mode: "create", + nodeType: "node-type-uid", + }), + ); + + const dialogProps = mocks.renderModifyNodeDialog.mock.calls[0][0]; + await dialogProps.onSuccess({ + action: "create", + text: "CLM - highlighted text", + uid: "new-node-uid", + }); + + expect(mocks.insertPageRefAtRange).toHaveBeenCalledWith({ + blockUid: "block-uid", + pageTitle: "CLM - highlighted text", + selectionEnd: 20, + selectionStart: 4, + windowId: "main-window", + }); + expect(onInserted).toHaveBeenCalledWith("CLM - highlighted text"); + }); +}); diff --git a/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts b/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts new file mode 100644 index 000000000..b02727479 --- /dev/null +++ b/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts @@ -0,0 +1,43 @@ +import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog"; +import { insertPageRefAtRange } from "~/utils/advancedSearchFooterUtils"; +import type { OnloadArgs } from "roamjs-components/types"; + +type OpenCreateNodeDialogFromSelectionArgs = { + blockUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; + nodeType: string; + onInserted?: (pageTitle: string) => void; + selectedText: string; + selectionEnd: number; + selectionStart: number; + windowId: string; +}; + +export const openCreateNodeDialogFromSelection = ({ + blockUid, + extensionAPI, + nodeType, + onInserted, + selectedText, + selectionEnd, + selectionStart, + windowId, +}: OpenCreateNodeDialogFromSelectionArgs): void => { + renderModifyNodeDialog({ + mode: "create", + nodeType, + initialValue: { text: selectedText, uid: "" }, + extensionAPI, + onSuccess: async (result) => { + await insertPageRefAtRange({ + blockUid, + pageTitle: result.text, + selectionEnd, + selectionStart, + windowId, + }); + onInserted?.(result.text); + }, + onClose: () => {}, + }); +}; From a234e7b2084ac6efdbf4ab434ae69ea17f671393 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 16 Aug 2026 22:03:59 -0600 Subject: [PATCH 2/5] Preserve block context during node formatting --- apps/roam/src/components/ModifyNodeDialog.tsx | 10 +++++++--- .../openCreateNodeDialogFromSelection.test.ts | 4 +++- apps/roam/src/utils/formatUtils.ts | 1 + .../src/utils/openCreateNodeDialogFromSelection.ts | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 0d48933a9..6b3700682 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -45,7 +45,9 @@ export type ModifyNodeDialogProps = { nodeType?: string; initialValue: { text: string; uid: string }; initialReferencedNode?: { text: string; uid: string }; - sourceBlockUid?: string; //the block that we started modifying from + sourceBlockUid?: string; + // Provides format resolution context without allowing the dialog to mutate the block. + contextBlockUid?: string; extensionAPI?: OnloadArgs["extensionAPI"]; includeDefaultNodes?: boolean; // Include default nodes (Page, Block) in node type selector imageUrl?: string; // For image conversion from canvas @@ -69,6 +71,7 @@ const ModifyNodeDialog = ({ initialValue, initialReferencedNode, sourceBlockUid, + contextBlockUid, extensionAPI, includeDefaultNodes = false, imageUrl, @@ -77,6 +80,7 @@ const ModifyNodeDialog = ({ onSuccess, onClose, }: RoamOverlayProps) => { + const formatContextBlockUid = contextBlockUid ?? sourceBlockUid; const [content, setContent] = useState({ text: initialValue.text, uid: initialValue.uid, @@ -386,7 +390,7 @@ const ModifyNodeDialog = ({ const formattedRefNode = await getNewDiscourseNodeText({ text: referencedNodeValue.text.trim(), nodeType: referencedNode.nodeType, - blockUid: sourceBlockUid, + blockUid: formatContextBlockUid, }); if (!formattedRefNode) { return; @@ -409,7 +413,7 @@ const ModifyNodeDialog = ({ formattedTitle = await getNewDiscourseNodeText({ text: content.text.trim(), nodeType: selectedNodeType.type, - blockUid: sourceBlockUid, + blockUid: formatContextBlockUid, }); } if (!formattedTitle) { diff --git a/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts b/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts index e7577270d..ddccef1b6 100644 --- a/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts +++ b/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts @@ -32,7 +32,7 @@ describe("openCreateNodeDialogFromSelection", () => { mocks.insertPageRefAtRange.mockResolvedValue(undefined); }); - it("prefills the dialog and replaces the selected text after creation", async () => { + it("prefills the dialog with format context and replaces the selected text", async () => { const extensionAPI = {} as OnloadArgs["extensionAPI"]; const onInserted = vi.fn(); @@ -49,6 +49,7 @@ describe("openCreateNodeDialogFromSelection", () => { expect(mocks.renderModifyNodeDialog).toHaveBeenCalledWith( expect.objectContaining({ + contextBlockUid: "block-uid", extensionAPI, initialValue: { text: "highlighted text", uid: "" }, mode: "create", @@ -57,6 +58,7 @@ describe("openCreateNodeDialogFromSelection", () => { ); const dialogProps = mocks.renderModifyNodeDialog.mock.calls[0][0]; + expect(dialogProps.sourceBlockUid).toBeUndefined(); await dialogProps.onSuccess({ action: "create", text: "CLM - highlighted text", diff --git a/apps/roam/src/utils/formatUtils.ts b/apps/roam/src/utils/formatUtils.ts index c807edfd6..e8c5fd35f 100644 --- a/apps/roam/src/utils/formatUtils.ts +++ b/apps/roam/src/utils/formatUtils.ts @@ -47,6 +47,7 @@ export const getNewDiscourseNodeText = async ({ resolvedText = result.text; return Promise.resolve(); }, + contextBlockUid: blockUid, sourceBlockUid: skipBlockUpdate ? undefined : blockUid, extensionAPI: getExtensionAPI(), onClose: () => { diff --git a/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts b/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts index b02727479..292f714d8 100644 --- a/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts +++ b/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts @@ -27,6 +27,7 @@ export const openCreateNodeDialogFromSelection = ({ mode: "create", nodeType, initialValue: { text: selectedText, uid: "" }, + contextBlockUid: blockUid, extensionAPI, onSuccess: async (result) => { await insertPageRefAtRange({ From 0e037855eea40b6b39f58350f063d3ce4233ed01 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 16 Aug 2026 22:06:51 -0600 Subject: [PATCH 3/5] Clarify source block and reference resolution comments --- apps/roam/src/components/ModifyNodeDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 6b3700682..a75b1cea5 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -45,8 +45,8 @@ export type ModifyNodeDialogProps = { nodeType?: string; initialValue: { text: string; uid: string }; initialReferencedNode?: { text: string; uid: string }; - sourceBlockUid?: string; - // Provides format resolution context without allowing the dialog to mutate the block. + sourceBlockUid?: string; //the block that we started modifying from + // Resolves referenced-node placeholders from this block without allowing the dialog to mutate it. contextBlockUid?: string; extensionAPI?: OnloadArgs["extensionAPI"]; includeDefaultNodes?: boolean; // Include default nodes (Page, Block) in node type selector From 30c10a857d817d105c17d90f6b2b2c593775b28c Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 16 Aug 2026 22:11:17 -0600 Subject: [PATCH 4/5] Revert "Clarify source block and reference resolution comments" This reverts commit 0e037855eea40b6b39f58350f063d3ce4233ed01. --- apps/roam/src/components/ModifyNodeDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index a75b1cea5..6b3700682 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -45,8 +45,8 @@ export type ModifyNodeDialogProps = { nodeType?: string; initialValue: { text: string; uid: string }; initialReferencedNode?: { text: string; uid: string }; - sourceBlockUid?: string; //the block that we started modifying from - // Resolves referenced-node placeholders from this block without allowing the dialog to mutate it. + sourceBlockUid?: string; + // Provides format resolution context without allowing the dialog to mutate the block. contextBlockUid?: string; extensionAPI?: OnloadArgs["extensionAPI"]; includeDefaultNodes?: boolean; // Include default nodes (Page, Block) in node type selector From d302c2ab68fec697e227e8c8a20b31f559498382 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 16 Aug 2026 22:11:17 -0600 Subject: [PATCH 5/5] Revert "Preserve block context during node formatting" This reverts commit a234e7b2084ac6efdbf4ab434ae69ea17f671393. --- apps/roam/src/components/ModifyNodeDialog.tsx | 10 +++------- .../openCreateNodeDialogFromSelection.test.ts | 4 +--- apps/roam/src/utils/formatUtils.ts | 1 - .../src/utils/openCreateNodeDialogFromSelection.ts | 1 - 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 6b3700682..0d48933a9 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -45,9 +45,7 @@ export type ModifyNodeDialogProps = { nodeType?: string; initialValue: { text: string; uid: string }; initialReferencedNode?: { text: string; uid: string }; - sourceBlockUid?: string; - // Provides format resolution context without allowing the dialog to mutate the block. - contextBlockUid?: string; + sourceBlockUid?: string; //the block that we started modifying from extensionAPI?: OnloadArgs["extensionAPI"]; includeDefaultNodes?: boolean; // Include default nodes (Page, Block) in node type selector imageUrl?: string; // For image conversion from canvas @@ -71,7 +69,6 @@ const ModifyNodeDialog = ({ initialValue, initialReferencedNode, sourceBlockUid, - contextBlockUid, extensionAPI, includeDefaultNodes = false, imageUrl, @@ -80,7 +77,6 @@ const ModifyNodeDialog = ({ onSuccess, onClose, }: RoamOverlayProps) => { - const formatContextBlockUid = contextBlockUid ?? sourceBlockUid; const [content, setContent] = useState({ text: initialValue.text, uid: initialValue.uid, @@ -390,7 +386,7 @@ const ModifyNodeDialog = ({ const formattedRefNode = await getNewDiscourseNodeText({ text: referencedNodeValue.text.trim(), nodeType: referencedNode.nodeType, - blockUid: formatContextBlockUid, + blockUid: sourceBlockUid, }); if (!formattedRefNode) { return; @@ -413,7 +409,7 @@ const ModifyNodeDialog = ({ formattedTitle = await getNewDiscourseNodeText({ text: content.text.trim(), nodeType: selectedNodeType.type, - blockUid: formatContextBlockUid, + blockUid: sourceBlockUid, }); } if (!formattedTitle) { diff --git a/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts b/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts index ddccef1b6..e7577270d 100644 --- a/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts +++ b/apps/roam/src/utils/__tests__/openCreateNodeDialogFromSelection.test.ts @@ -32,7 +32,7 @@ describe("openCreateNodeDialogFromSelection", () => { mocks.insertPageRefAtRange.mockResolvedValue(undefined); }); - it("prefills the dialog with format context and replaces the selected text", async () => { + it("prefills the dialog and replaces the selected text after creation", async () => { const extensionAPI = {} as OnloadArgs["extensionAPI"]; const onInserted = vi.fn(); @@ -49,7 +49,6 @@ describe("openCreateNodeDialogFromSelection", () => { expect(mocks.renderModifyNodeDialog).toHaveBeenCalledWith( expect.objectContaining({ - contextBlockUid: "block-uid", extensionAPI, initialValue: { text: "highlighted text", uid: "" }, mode: "create", @@ -58,7 +57,6 @@ describe("openCreateNodeDialogFromSelection", () => { ); const dialogProps = mocks.renderModifyNodeDialog.mock.calls[0][0]; - expect(dialogProps.sourceBlockUid).toBeUndefined(); await dialogProps.onSuccess({ action: "create", text: "CLM - highlighted text", diff --git a/apps/roam/src/utils/formatUtils.ts b/apps/roam/src/utils/formatUtils.ts index e8c5fd35f..c807edfd6 100644 --- a/apps/roam/src/utils/formatUtils.ts +++ b/apps/roam/src/utils/formatUtils.ts @@ -47,7 +47,6 @@ export const getNewDiscourseNodeText = async ({ resolvedText = result.text; return Promise.resolve(); }, - contextBlockUid: blockUid, sourceBlockUid: skipBlockUpdate ? undefined : blockUid, extensionAPI: getExtensionAPI(), onClose: () => { diff --git a/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts b/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts index 292f714d8..b02727479 100644 --- a/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts +++ b/apps/roam/src/utils/openCreateNodeDialogFromSelection.ts @@ -27,7 +27,6 @@ export const openCreateNodeDialogFromSelection = ({ mode: "create", nodeType, initialValue: { text: selectedText, uid: "" }, - contextBlockUid: blockUid, extensionAPI, onSuccess: async (result) => { await insertPageRefAtRange({