Skip to content

Commit 3e97235

Browse files
Roam: ENG-622: Node tag to node conversions (#304)
* real time validation * use on blur validation, add tag to config page, settings, extract with other data * conditionally show node tag * use live validation, don't save conflicting state to roam * press down key to trigger * coderabbit review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * update block text * node tage to node conversion * Revert "node tage to node conversion" This reverts commit 3cdac68. * node tage to node conversion * refactor and review ' * address coderabbit * address review * --amend * fix arrow movement while shift, default menu selection or not * address review * use popover and hover listeners to fix rendering * moving out * use observer * fix style * placeholder text --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 6a7399d commit 3e97235

4 files changed

Lines changed: 285 additions & 3 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { Dialog, Classes, InputGroup, Label, Button } from "@blueprintjs/core";
3+
import renderOverlay from "roamjs-components/util/renderOverlay";
4+
import createDiscourseNode from "~/utils/createDiscourseNode";
5+
import { OnloadArgs } from "roamjs-components/types";
6+
import updateBlock from "roamjs-components/writes/updateBlock";
7+
import { render as renderToast } from "roamjs-components/components/Toast";
8+
import getDiscourseNodes, {
9+
DiscourseNode,
10+
excludeDefaultNodes,
11+
} from "~/utils/getDiscourseNodes";
12+
import { getNewDiscourseNodeText } from "~/utils/formatUtils";
13+
import MenuItemSelect from "roamjs-components/components/MenuItemSelect";
14+
15+
export type CreateNodeDialogProps = {
16+
onClose: () => void;
17+
defaultNodeTypeUid: string;
18+
extensionAPI: OnloadArgs["extensionAPI"];
19+
sourceBlockUid?: string;
20+
initialTitle: string;
21+
};
22+
23+
const CreateNodeDialog = ({
24+
onClose,
25+
defaultNodeTypeUid,
26+
extensionAPI,
27+
sourceBlockUid,
28+
initialTitle,
29+
}: CreateNodeDialogProps) => {
30+
const discourseNodes = getDiscourseNodes().filter(excludeDefaultNodes);
31+
const defaultNodeType =
32+
discourseNodes.find((n) => n.type === defaultNodeTypeUid) ||
33+
discourseNodes[0];
34+
35+
const [title, setTitle] = useState(initialTitle);
36+
const [selectedType, setSelectedType] =
37+
useState<DiscourseNode>(defaultNodeType);
38+
const [loading, setLoading] = useState(false);
39+
const inputRef = useRef<HTMLInputElement>(null);
40+
41+
useEffect(() => {
42+
if (inputRef.current) {
43+
inputRef.current.focus();
44+
}
45+
}, []);
46+
47+
const onCreate = async () => {
48+
if (!title.trim()) return;
49+
setLoading(true);
50+
51+
const formattedTitle = await getNewDiscourseNodeText({
52+
text: title.trim(),
53+
nodeType: selectedType.type,
54+
blockUid: sourceBlockUid,
55+
});
56+
57+
if (!formattedTitle) {
58+
setLoading(false);
59+
return;
60+
}
61+
62+
const newPageUid = await createDiscourseNode({
63+
text: formattedTitle,
64+
configPageUid: selectedType.type,
65+
extensionAPI,
66+
});
67+
68+
if (sourceBlockUid) {
69+
// TODO: This assumes the new node is always a page. If the specification
70+
// defines it as a block (e.g., "is in page with title"), this will not create
71+
// the correct reference. The reference format should be determined by the
72+
// node's specification.
73+
const pageRef = `[[${formattedTitle}]]`;
74+
await updateBlock({ uid: sourceBlockUid, text: pageRef });
75+
76+
const newCursorPosition = pageRef.length;
77+
const windowId =
78+
window.roamAlphaAPI.ui.getFocusedBlock?.()?.["window-id"] || "main";
79+
80+
await window.roamAlphaAPI.ui.setBlockFocusAndSelection({
81+
location: { "block-uid": sourceBlockUid, "window-id": windowId },
82+
selection: { start: newCursorPosition },
83+
});
84+
}
85+
86+
renderToast({
87+
id: `discourse-node-created-${Date.now()}`,
88+
intent: "success",
89+
timeout: 10000,
90+
content: (
91+
<span>
92+
Created node{" "}
93+
<a
94+
className="cursor-pointer font-medium text-blue-500 hover:underline"
95+
onClick={async (event) => {
96+
if (event.shiftKey) {
97+
await window.roamAlphaAPI.ui.rightSidebar.addWindow({
98+
window: {
99+
"block-uid": newPageUid,
100+
type: "outline",
101+
},
102+
});
103+
} else {
104+
await window.roamAlphaAPI.ui.mainWindow.openPage({
105+
page: { uid: newPageUid },
106+
});
107+
}
108+
}}
109+
>
110+
[[{formattedTitle}]]
111+
</a>
112+
</span>
113+
),
114+
});
115+
setLoading(false);
116+
onClose();
117+
};
118+
119+
return (
120+
<Dialog
121+
isOpen={true}
122+
onClose={onClose}
123+
title="Create Discourse Node"
124+
autoFocus={false}
125+
>
126+
<div className={Classes.DIALOG_BODY}>
127+
<div className="flex flex-col gap-4">
128+
<div>
129+
<label className="mb-1 block font-bold">Title</label>
130+
<InputGroup
131+
placeholder={`This is a potential ${selectedType.text.toLowerCase()}`}
132+
value={title}
133+
onChange={(e) => setTitle(e.currentTarget.value)}
134+
inputRef={inputRef}
135+
/>
136+
</div>
137+
138+
<Label>
139+
Type
140+
<MenuItemSelect
141+
items={discourseNodes.map((n) => n.type)}
142+
transformItem={(t) =>
143+
discourseNodes.find((n) => n.type === t)?.text || t
144+
}
145+
activeItem={selectedType.type}
146+
onItemSelect={(t) => {
147+
const nt = discourseNodes.find((n) => n.type === t);
148+
if (nt) setSelectedType(nt);
149+
}}
150+
/>
151+
</Label>
152+
</div>
153+
</div>
154+
<div className={Classes.DIALOG_FOOTER}>
155+
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
156+
<Button minimal onClick={onClose} disabled={loading}>
157+
Cancel
158+
</Button>
159+
<Button
160+
intent="primary"
161+
onClick={onCreate}
162+
disabled={!title.trim() || loading}
163+
loading={loading}
164+
>
165+
Create
166+
</Button>
167+
</div>
168+
</div>
169+
</Dialog>
170+
);
171+
};
172+
173+
export const renderCreateNodeDialog = (props: CreateNodeDialogProps) =>
174+
renderOverlay({
175+
Overlay: CreateNodeDialog,
176+
props,
177+
});

apps/roam/src/components/settings/NodeConfig.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const ValidatedInputPanel = ({
3333
error: string;
3434
placeholder?: string;
3535
}) => (
36-
<>
36+
<div className="flex flex-col">
3737
<Label>
3838
{label}
3939
<Description description={description} />
@@ -47,7 +47,7 @@ const ValidatedInputPanel = ({
4747
{error && (
4848
<div className="mt-1 text-sm font-medium text-red-600">{error}</div>
4949
)}
50-
</>
50+
</div>
5151
);
5252

5353
const useDebouncedRoamUpdater = (
@@ -229,7 +229,7 @@ const NodeConfig = ({
229229
onChange={handleTagChange}
230230
onBlur={handleTagBlur}
231231
error={tagError}
232-
placeholder={`#${node.text}`}
232+
placeholder={`${node.text}`}
233233
/>
234234
</div>
235235
}

apps/roam/src/utils/initializeObserversAndListeners.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
removeTextSelectionPopup,
4141
findBlockElementFromSelection,
4242
} from "~/utils/renderTextSelectionPopup";
43+
import { renderNodeTagPopupButton } from "./renderNodeTagPopup";
4344

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

97+
const nodeTagPopupButtonObserver = createHTMLObserver({
98+
className: "rm-page-ref--tag",
99+
tag: "SPAN",
100+
callback: (s: HTMLSpanElement) => {
101+
renderNodeTagPopupButton(s, onloadArgs.extensionAPI);
102+
},
103+
});
104+
96105
const pageActionListener = ((
97106
e: CustomEvent<{
98107
action: string;
@@ -300,6 +309,7 @@ export const initObservers = async ({
300309
configPageObserver,
301310
linkedReferencesObserver,
302311
graphOverviewExportObserver,
312+
nodeTagPopupButtonObserver,
303313
].filter((o): o is MutationObserver => !!o),
304314
listeners: {
305315
pageActionListener,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React from "react";
2+
import ReactDOM from "react-dom";
3+
import { Button, Popover, Position } from "@blueprintjs/core";
4+
import { renderCreateNodeDialog } from "~/components/CreateNodeDialog";
5+
import { OnloadArgs } from "roamjs-components/types";
6+
import getUids from "roamjs-components/dom/getUids";
7+
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
8+
import getDiscourseNodes from "./getDiscourseNodes";
9+
10+
export const renderNodeTagPopupButton = (
11+
parent: HTMLSpanElement,
12+
extensionAPI: OnloadArgs["extensionAPI"],
13+
) => {
14+
if (parent.dataset.attributeButtonRendered === "true") return;
15+
16+
parent.dataset.attributeButtonRendered = "true";
17+
const wrapper = document.createElement("span");
18+
wrapper.style.position = "relative";
19+
wrapper.style.display = "inline-block";
20+
parent.parentNode?.insertBefore(wrapper, parent);
21+
wrapper.appendChild(parent);
22+
23+
const reactRoot = document.createElement("span");
24+
reactRoot.style.position = "absolute";
25+
reactRoot.style.top = "0";
26+
reactRoot.style.left = "0";
27+
reactRoot.style.width = "100%";
28+
reactRoot.style.height = "100%";
29+
reactRoot.style.pointerEvents = "auto";
30+
reactRoot.style.zIndex = "10";
31+
32+
wrapper.appendChild(reactRoot);
33+
34+
const textContent = parent.textContent?.trim() || "";
35+
const tagAttr = parent.getAttribute("data-tag") || textContent;
36+
const tag = tagAttr.replace(/^#/, "").toLowerCase();
37+
const discourseNodes = getDiscourseNodes();
38+
const discourseTagSet = new Set(
39+
discourseNodes.map((n) => n.tag?.toLowerCase()).filter(Boolean),
40+
);
41+
if (!discourseTagSet.has(tag)) return;
42+
43+
const matchedNode = discourseNodes.find((n) => n.tag?.toLowerCase() === tag);
44+
45+
if (!matchedNode) return;
46+
47+
const blockInputElement = parent.closest(".rm-block__input");
48+
const blockUid = blockInputElement
49+
? getUids(blockInputElement as HTMLDivElement).blockUid
50+
: undefined;
51+
52+
const rawBlockText = blockUid ? getTextByBlockUid(blockUid) : "";
53+
const cleanedBlockText = rawBlockText.replace(textContent, "").trim();
54+
55+
ReactDOM.render(
56+
<Popover
57+
content={
58+
<Button
59+
minimal
60+
outlined
61+
onClick={() => {
62+
renderCreateNodeDialog({
63+
onClose: () => {},
64+
defaultNodeTypeUid: matchedNode.type,
65+
extensionAPI,
66+
sourceBlockUid: blockUid,
67+
initialTitle: cleanedBlockText,
68+
});
69+
}}
70+
text={`Create ${matchedNode.text}`}
71+
/>
72+
}
73+
target={
74+
<span
75+
style={{
76+
display: "block",
77+
width: "100%",
78+
height: "100%",
79+
}}
80+
/>
81+
}
82+
interactionKind="hover"
83+
position={Position.TOP}
84+
modifiers={{
85+
offset: {
86+
offset: "0, 10",
87+
},
88+
arrow: {
89+
enabled: false,
90+
},
91+
}}
92+
/>,
93+
reactRoot,
94+
);
95+
};

0 commit comments

Comments
 (0)