Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
958cdee
real time validation
sid597 Jul 21, 2025
27bd7e9
use on blur validation, add tag to config page, settings, extract wit…
sid597 Jul 21, 2025
3d3558c
conditionally show node tag
sid597 Jul 21, 2025
bfa4ea7
use live validation, don't save conflicting state to roam
sid597 Jul 25, 2025
a660726
Merge branch 'eng-610-tag-assignment-in-roam-dg-plugin-settings-menu'…
sid597 Jul 25, 2025
2fcc70c
press down key to trigger
sid597 Jul 25, 2025
6a2966e
coderabbit review
sid597 Jul 25, 2025
8f350cb
update block text
sid597 Jul 25, 2025
dc214f2
Merge branch 'eng-621-show-node-tag-options-in-inline-node-creation-m…
sid597 Jul 25, 2025
3cdac68
node tage to node conversion
sid597 Jul 25, 2025
cd46113
Revert "node tage to node conversion"
sid597 Jul 25, 2025
fc76dbf
node tage to node conversion
sid597 Jul 25, 2025
c9c6d7d
refactor and review '
sid597 Jul 26, 2025
72d0016
address coderabbit
sid597 Jul 26, 2025
3b2d92b
address review
sid597 Jul 27, 2025
ef89ce4
--amend
sid597 Jul 27, 2025
031ef17
fix arrow movement while shift, default menu selection or not
sid597 Jul 27, 2025
e4aa456
Merge branch 'eng-621-show-node-tag-options-in-inline-node-creation-m…
sid597 Jul 27, 2025
b39a1fb
address review
sid597 Jul 27, 2025
61e1f87
use popover and hover listeners to fix rendering
sid597 Jul 28, 2025
7a0f6e5
moving out
sid597 Jul 29, 2025
6281a07
Merge branch 'main' into eng-622-node-tag-to-node-conversions
sid597 Aug 4, 2025
f69a04f
use observer
sid597 Aug 4, 2025
d38c473
fix style
sid597 Aug 4, 2025
95e7b7b
placeholder text
sid597 Aug 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions apps/roam/src/components/CreateNodeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React, { useEffect, useRef, useState } from "react";
import { Dialog, Classes, InputGroup, Label, Button } from "@blueprintjs/core";
import renderOverlay from "roamjs-components/util/renderOverlay";
import createDiscourseNode from "~/utils/createDiscourseNode";
import { OnloadArgs } from "roamjs-components/types";
import updateBlock from "roamjs-components/writes/updateBlock";
import { render as renderToast } from "roamjs-components/components/Toast";
import getDiscourseNodes, {
DiscourseNode,
excludeDefaultNodes,
} from "~/utils/getDiscourseNodes";
import { getNewDiscourseNodeText } from "~/utils/formatUtils";
import MenuItemSelect from "roamjs-components/components/MenuItemSelect";

export type CreateNodeDialogProps = {
onClose: () => void;
defaultNodeTypeUid: string;
extensionAPI: OnloadArgs["extensionAPI"];
sourceBlockUid?: string;
initialTitle: string;
};

const CreateNodeDialog = ({
onClose,
defaultNodeTypeUid,
extensionAPI,
sourceBlockUid,
initialTitle,
}: CreateNodeDialogProps) => {
const discourseNodes = getDiscourseNodes().filter(excludeDefaultNodes);
const defaultNodeType =
discourseNodes.find((n) => n.type === defaultNodeTypeUid) ||
discourseNodes[0];

const [title, setTitle] = useState(initialTitle);
const [selectedType, setSelectedType] =
useState<DiscourseNode>(defaultNodeType);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);

const onCreate = async () => {
if (!title.trim()) return;
setLoading(true);

const formattedTitle = await getNewDiscourseNodeText({
text: title.trim(),
nodeType: selectedType.type,
blockUid: sourceBlockUid,
});

if (!formattedTitle) {
setLoading(false);
return;
}

const newPageUid = await createDiscourseNode({
text: formattedTitle,
configPageUid: selectedType.type,
extensionAPI,
});

if (sourceBlockUid) {
// TODO: This assumes the new node is always a page. If the specification
// defines it as a block (e.g., "is in page with title"), this will not create
// the correct reference. The reference format should be determined by the
// node's specification.
const pageRef = `[[${formattedTitle}]]`;
await updateBlock({ uid: sourceBlockUid, text: pageRef });

const newCursorPosition = pageRef.length;
const windowId =
window.roamAlphaAPI.ui.getFocusedBlock?.()?.["window-id"] || "main";

await window.roamAlphaAPI.ui.setBlockFocusAndSelection({
location: { "block-uid": sourceBlockUid, "window-id": windowId },
selection: { start: newCursorPosition },
});
}

renderToast({
id: `discourse-node-created-${Date.now()}`,
intent: "success",
timeout: 10000,
content: (
<span>
Created node{" "}
<a
className="cursor-pointer font-medium text-blue-500 hover:underline"
onClick={async (event) => {
if (event.shiftKey) {
await window.roamAlphaAPI.ui.rightSidebar.addWindow({
window: {
"block-uid": newPageUid,
type: "outline",
},
});
} else {
await window.roamAlphaAPI.ui.mainWindow.openPage({
page: { uid: newPageUid },
});
}
}}
>
[[{formattedTitle}]]
</a>
</span>
),
});
setLoading(false);
onClose();
};

return (
<Dialog
isOpen={true}
onClose={onClose}
title="Create Discourse Node"
autoFocus={false}
>
<div className={Classes.DIALOG_BODY}>
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 block font-bold">Title</label>
<InputGroup
placeholder={`This is a potential ${selectedType.text.toLowerCase()}`}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
inputRef={inputRef}
/>
</div>

<Label>
Type
<MenuItemSelect
items={discourseNodes.map((n) => n.type)}
transformItem={(t) =>
discourseNodes.find((n) => n.type === t)?.text || t
}
activeItem={selectedType.type}
onItemSelect={(t) => {
const nt = discourseNodes.find((n) => n.type === t);
if (nt) setSelectedType(nt);
}}
/>
</Label>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button minimal onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
intent="primary"
onClick={onCreate}
disabled={!title.trim() || loading}
loading={loading}
>
Create
</Button>
</div>
</div>
</Dialog>
);
};

export const renderCreateNodeDialog = (props: CreateNodeDialogProps) =>
renderOverlay({
Overlay: CreateNodeDialog,
props,
});
6 changes: 3 additions & 3 deletions apps/roam/src/components/settings/NodeConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const ValidatedInputPanel = ({
error: string;
placeholder?: string;
}) => (
<>
<div className="flex flex-col">
<Label>
{label}
<Description description={description} />
Expand All @@ -47,7 +47,7 @@ const ValidatedInputPanel = ({
{error && (
<div className="mt-1 text-sm font-medium text-red-600">{error}</div>
)}
</>
</div>
);

const useDebouncedRoamUpdater = (
Expand Down Expand Up @@ -229,7 +229,7 @@ const NodeConfig = ({
onChange={handleTagChange}
onBlur={handleTagBlur}
error={tagError}
placeholder={`#${node.text}`}
Copy link
Contributor

@mdroidian mdroidian Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I liked the # 😁.

I'm going to create a task to handle #, as we discussed today. We can re-add it once the use case is handled 😃

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

placeholder={`${node.text}`}
/>
</div>
}
Expand Down
10 changes: 10 additions & 0 deletions apps/roam/src/utils/initializeObserversAndListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
removeTextSelectionPopup,
findBlockElementFromSelection,
} from "~/utils/renderTextSelectionPopup";
import { renderNodeTagPopupButton } from "./renderNodeTagPopup";

const debounce = (fn: () => void, delay = 250) => {
let timeout: number;
Expand Down Expand Up @@ -93,6 +94,14 @@ export const initObservers = async ({
render: (b) => renderQueryBlock(b, onloadArgs),
});

const nodeTagPopupButtonObserver = createHTMLObserver({
className: "rm-page-ref--tag",
tag: "SPAN",
callback: (s: HTMLSpanElement) => {
renderNodeTagPopupButton(s, onloadArgs.extensionAPI);
},
});

const pageActionListener = ((
e: CustomEvent<{
action: string;
Expand Down Expand Up @@ -300,6 +309,7 @@ export const initObservers = async ({
configPageObserver,
linkedReferencesObserver,
graphOverviewExportObserver,
nodeTagPopupButtonObserver,
].filter((o): o is MutationObserver => !!o),
listeners: {
pageActionListener,
Expand Down
95 changes: 95 additions & 0 deletions apps/roam/src/utils/renderNodeTagPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from "react";
import ReactDOM from "react-dom";
import { Button, Popover, Position } from "@blueprintjs/core";
import { renderCreateNodeDialog } from "~/components/CreateNodeDialog";
import { OnloadArgs } from "roamjs-components/types";
import getUids from "roamjs-components/dom/getUids";
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
import getDiscourseNodes from "./getDiscourseNodes";

export const renderNodeTagPopupButton = (
parent: HTMLSpanElement,
extensionAPI: OnloadArgs["extensionAPI"],
) => {
if (parent.dataset.attributeButtonRendered === "true") return;

parent.dataset.attributeButtonRendered = "true";
const wrapper = document.createElement("span");
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
parent.parentNode?.insertBefore(wrapper, parent);
wrapper.appendChild(parent);

const reactRoot = document.createElement("span");
reactRoot.style.position = "absolute";
reactRoot.style.top = "0";
reactRoot.style.left = "0";
reactRoot.style.width = "100%";
reactRoot.style.height = "100%";
reactRoot.style.pointerEvents = "auto";
reactRoot.style.zIndex = "10";

wrapper.appendChild(reactRoot);

const textContent = parent.textContent?.trim() || "";
const tagAttr = parent.getAttribute("data-tag") || textContent;
const tag = tagAttr.replace(/^#/, "").toLowerCase();
const discourseNodes = getDiscourseNodes();
const discourseTagSet = new Set(
discourseNodes.map((n) => n.tag?.toLowerCase()).filter(Boolean),
);
if (!discourseTagSet.has(tag)) return;

const matchedNode = discourseNodes.find((n) => n.tag?.toLowerCase() === tag);

if (!matchedNode) return;

const blockInputElement = parent.closest(".rm-block__input");
const blockUid = blockInputElement
? getUids(blockInputElement as HTMLDivElement).blockUid
: undefined;

const rawBlockText = blockUid ? getTextByBlockUid(blockUid) : "";
const cleanedBlockText = rawBlockText.replace(textContent, "").trim();

ReactDOM.render(
<Popover
content={
<Button
minimal
outlined
onClick={() => {
renderCreateNodeDialog({
onClose: () => {},
defaultNodeTypeUid: matchedNode.type,
extensionAPI,
sourceBlockUid: blockUid,
initialTitle: cleanedBlockText,
});
}}
text={`Create ${matchedNode.text}`}
/>
}
target={
<span
style={{
display: "block",
width: "100%",
height: "100%",
}}
/>
}
interactionKind="hover"
position={Position.TOP}
modifiers={{
offset: {
offset: "0, 10",
},
arrow: {
enabled: false,
},
}}
/>,
reactRoot,
);
};