Skip to content

Commit fc76dbf

Browse files
committed
node tage to node conversion
1 parent cd46113 commit fc76dbf

File tree

5 files changed

+286
-1
lines changed

5 files changed

+286
-1
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import {
3+
Dialog,
4+
Classes,
5+
InputGroup,
6+
HTMLSelect,
7+
Button,
8+
} from "@blueprintjs/core";
9+
import renderOverlay from "roamjs-components/util/renderOverlay";
10+
import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes";
11+
import createDiscourseNode from "~/utils/createDiscourseNode";
12+
import { OnloadArgs } from "roamjs-components/types";
13+
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
14+
import updateBlock from "roamjs-components/writes/updateBlock";
15+
import { render as renderToast } from "roamjs-components/components/Toast";
16+
import getUids from "roamjs-components/dom/getUids";
17+
18+
export type CreateNodeDialogProps = {
19+
isOpen: boolean;
20+
onClose: () => void;
21+
nodeTypes: DiscourseNode[];
22+
defaultNodeType: DiscourseNode;
23+
extensionAPI: OnloadArgs["extensionAPI"];
24+
blockUid?: string;
25+
originalTagText: string; // e.g. "#qa"
26+
initialTitle: string; // cleaned text without tag
27+
};
28+
29+
const CreateNodeDialog = ({
30+
isOpen,
31+
onClose,
32+
nodeTypes,
33+
defaultNodeType,
34+
extensionAPI,
35+
blockUid,
36+
originalTagText,
37+
initialTitle,
38+
}: CreateNodeDialogProps) => {
39+
const [title, setTitle] = useState(initialTitle);
40+
const [selectedType, setSelectedType] =
41+
useState<DiscourseNode>(defaultNodeType);
42+
const [loading, setLoading] = useState(false);
43+
const inputRef = useRef<HTMLInputElement>(null);
44+
45+
useEffect(() => {
46+
setTimeout(() => inputRef.current?.focus(), 50);
47+
}, []);
48+
49+
const onCreate = async () => {
50+
if (!title.trim()) return;
51+
setLoading(true);
52+
53+
const format = (
54+
getDiscourseNodes().find((n) => n.type === selectedType.type)?.format ||
55+
""
56+
).trim();
57+
58+
let formattedTitle: string;
59+
if (!format) {
60+
formattedTitle = title.trim();
61+
} else if (/{text}/i.test(format) || /{content}/i.test(format)) {
62+
formattedTitle = format
63+
.replace(/{text}/gi, title.trim())
64+
.replace(/{content}/gi, title.trim());
65+
} else {
66+
// If no placeholder, append the title after the format string
67+
formattedTitle = `${format} ${title.trim()}`.trim();
68+
}
69+
70+
const newPageUid = await createDiscourseNode({
71+
text: formattedTitle,
72+
configPageUid: selectedType.type, // In DiscourseNode struct type is uid
73+
extensionAPI,
74+
});
75+
// Replace original tag with new page reference
76+
if (blockUid) {
77+
const pageRef = `[[${formattedTitle}]]`;
78+
await updateBlock({ uid: blockUid, text: pageRef });
79+
80+
const newCursorPosition = pageRef.length;
81+
const windowId =
82+
window.roamAlphaAPI.ui.getFocusedBlock?.()?.["window-id"] || "main";
83+
84+
if (window.roamAlphaAPI.ui.setBlockFocusAndSelection) {
85+
window.roamAlphaAPI.ui.setBlockFocusAndSelection({
86+
location: { "block-uid": blockUid, "window-id": windowId },
87+
selection: { start: newCursorPosition },
88+
});
89+
} else {
90+
setTimeout(() => {
91+
const textareaElements = document.querySelectorAll("textarea");
92+
for (const el of textareaElements) {
93+
if (getUids(el as HTMLTextAreaElement).blockUid === blockUid) {
94+
(el as HTMLTextAreaElement).focus();
95+
(el as HTMLTextAreaElement).setSelectionRange(
96+
newCursorPosition,
97+
newCursorPosition,
98+
);
99+
break;
100+
}
101+
}
102+
}, 50);
103+
}
104+
}
105+
106+
// Toast confirmation
107+
renderToast({
108+
id: `discourse-node-created-${Date.now()}`,
109+
intent: "success",
110+
timeout: 10000,
111+
content: `Created node [[${formattedTitle}]]`,
112+
});
113+
setLoading(false);
114+
onClose();
115+
};
116+
117+
return (
118+
<Dialog
119+
isOpen={isOpen}
120+
onClose={onClose}
121+
title="Create node"
122+
autoFocus={false}
123+
>
124+
<div className={Classes.DIALOG_BODY}>
125+
<div className="flex flex-col gap-4">
126+
<div>
127+
<label className="mb-1 block font-bold">Title</label>
128+
<InputGroup
129+
placeholder={`This is a potential ${selectedType.text.toLowerCase()}`}
130+
value={title}
131+
onChange={(e) => setTitle(e.currentTarget.value)}
132+
inputRef={inputRef}
133+
/>
134+
</div>
135+
136+
<div>
137+
<label className="mb-1 block font-bold">Type</label>
138+
<HTMLSelect
139+
fill
140+
value={selectedType.type}
141+
onChange={(e) => {
142+
const nt = nodeTypes.find(
143+
(n) => n.type === e.currentTarget.value,
144+
);
145+
if (nt) setSelectedType(nt);
146+
}}
147+
>
148+
{nodeTypes.map((nt) => (
149+
<option key={nt.type} value={nt.type}>
150+
{nt.text}
151+
</option>
152+
))}
153+
</HTMLSelect>
154+
</div>
155+
</div>
156+
</div>
157+
<div className={Classes.DIALOG_FOOTER}>
158+
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
159+
<Button minimal onClick={onClose} disabled={loading}>
160+
Cancel
161+
</Button>
162+
<Button
163+
intent="primary"
164+
onClick={onCreate}
165+
disabled={!title.trim() || loading}
166+
loading={loading}
167+
>
168+
Create
169+
</Button>
170+
</div>
171+
</div>
172+
</Dialog>
173+
);
174+
};
175+
176+
export const renderCreateNodeDialog = (
177+
props: Omit<CreateNodeDialogProps, "isOpen">,
178+
) =>
179+
renderOverlay({
180+
Overlay: CreateNodeDialog,
181+
props: { ...props, isOpen: true },
182+
});

apps/roam/src/components/DiscourseNodeMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ const NodeMenu = ({
242242
key={item.text}
243243
data-node={item.type}
244244
data-tag={item.tag}
245-
text={showNodeTypes ? item.text : `#${item.tag || 'untagged'}`}
245+
text={showNodeTypes ? item.text : `#${item.tag || "untagged"}`}
246246
active={i === activeIndex}
247247
onMouseEnter={() => setActiveIndex(i)}
248248
onClick={() => onSelect(i)}

apps/roam/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,14 @@ export default runExtension(async (onloadArgs) => {
102102
nodeMenuTriggerListener,
103103
discourseNodeSearchTriggerListener,
104104
nodeCreationPopoverListener,
105+
nodeTagHoverListener,
105106
} = listeners;
106107
document.addEventListener("roamjs:query-builder:action", pageActionListener);
107108
window.addEventListener("hashchange", hashChangeListener);
108109
document.addEventListener("keydown", nodeMenuTriggerListener);
109110
document.addEventListener("input", discourseNodeSearchTriggerListener);
110111
document.addEventListener("selectionchange", nodeCreationPopoverListener);
112+
document.addEventListener("mouseover", nodeTagHoverListener);
111113

112114
await initializeDiscourseNodes();
113115
refreshConfigTree();
@@ -146,6 +148,7 @@ export default runExtension(async (onloadArgs) => {
146148
"selectionchange",
147149
nodeCreationPopoverListener,
148150
);
151+
document.removeEventListener("mouseover", nodeTagHoverListener);
149152
window.roamAlphaAPI.ui.graphView.wholeGraph.removeCallback({
150153
label: "discourse-node-styling",
151154
});

apps/roam/src/utils/initializeObserversAndListeners.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ import {
4040
removeTextSelectionPopup,
4141
findBlockElementFromSelection,
4242
} from "~/utils/renderTextSelectionPopup";
43+
import { renderNodeTagPopup, removeNodeTagPopup } from "./renderNodeTagPopup";
44+
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
45+
import { renderCreateNodeDialog } from "~/components/CreateNodeDialog";
46+
import getUids from "roamjs-components/dom/getUids";
4347

4448
export const initObservers = async ({
4549
onloadArgs,
@@ -53,6 +57,7 @@ export const initObservers = async ({
5357
nodeMenuTriggerListener: EventListener;
5458
discourseNodeSearchTriggerListener: EventListener;
5559
nodeCreationPopoverListener: EventListener;
60+
nodeTagHoverListener: EventListener;
5661
};
5762
}> => {
5863
const pageTitleObserver = createHTMLObserver({
@@ -289,6 +294,54 @@ export const initObservers = async ({
289294
}
290295
};
291296

297+
const nodeTagHoverListener = (e: Event) => {
298+
const target = e.target as HTMLElement | null;
299+
if (!target || !target.classList?.contains("rm-page-ref")) return;
300+
301+
const textContent = target.textContent?.trim() || "";
302+
const tagAttr = target.getAttribute("data-link-title") || textContent;
303+
const tag = tagAttr.replace(/^#/, "").toLowerCase();
304+
const discourseTagSet = new Set(
305+
getDiscourseNodes()
306+
.map((n) => n.tag?.toLowerCase())
307+
.filter(Boolean) as string[],
308+
);
309+
310+
if (!discourseTagSet.has(tag)) return;
311+
312+
const matchedNode = getDiscourseNodes().find(
313+
(n) => n.tag?.toLowerCase() === tag,
314+
);
315+
316+
if (!matchedNode) return;
317+
318+
const blockInputElement = (e.target as HTMLElement).closest(
319+
".rm-block__input",
320+
);
321+
const blockUid = blockInputElement
322+
? getUids(blockInputElement as HTMLDivElement).blockUid
323+
: undefined;
324+
325+
const rawBlockText = blockUid ? getTextByBlockUid(blockUid) : "";
326+
const cleanedBlockText = rawBlockText.replace(/#\w+/g, "").trim();
327+
328+
renderNodeTagPopup({
329+
tagElement: target,
330+
label: `+ Create ${matchedNode.text}`,
331+
onClick: () => {
332+
renderCreateNodeDialog({
333+
onClose: removeNodeTagPopup,
334+
nodeTypes: getDiscourseNodes(),
335+
defaultNodeType: matchedNode,
336+
extensionAPI: onloadArgs.extensionAPI,
337+
blockUid,
338+
originalTagText: textContent,
339+
initialTitle: cleanedBlockText,
340+
});
341+
},
342+
});
343+
};
344+
292345
return {
293346
observers: [
294347
pageTitleObserver,
@@ -303,6 +356,7 @@ export const initObservers = async ({
303356
nodeMenuTriggerListener,
304357
discourseNodeSearchTriggerListener,
305358
nodeCreationPopoverListener,
359+
nodeTagHoverListener,
306360
},
307361
};
308362
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from "react";
2+
import ReactDOM from "react-dom";
3+
import { Button } from "@blueprintjs/core";
4+
5+
let currentPopup: HTMLDivElement | null = null;
6+
7+
export const removeNodeTagPopup = () => {
8+
if (currentPopup) {
9+
ReactDOM.unmountComponentAtNode(currentPopup);
10+
currentPopup.remove();
11+
currentPopup = null;
12+
}
13+
};
14+
15+
export const renderNodeTagPopup = ({
16+
tagElement,
17+
onClick,
18+
label = "Create node",
19+
}: {
20+
tagElement: HTMLElement;
21+
onClick: () => void;
22+
label?: string;
23+
}) => {
24+
removeNodeTagPopup();
25+
26+
const rect = tagElement.getBoundingClientRect();
27+
28+
currentPopup = document.createElement("div");
29+
currentPopup.id = "discourse-node-tag-popup";
30+
currentPopup.style.position = "absolute";
31+
currentPopup.style.left = `${rect.left + window.scrollX}px`;
32+
currentPopup.style.top = `${rect.bottom + window.scrollY + 4}px`;
33+
currentPopup.className = "z-[9999] max-w-none font-inherit bg-white";
34+
35+
document.body.appendChild(currentPopup);
36+
37+
// Remove when pointer leaves the popup
38+
currentPopup.addEventListener("mouseleave", removeNodeTagPopup, {
39+
once: true,
40+
});
41+
42+
ReactDOM.render(
43+
<Button intent="primary" minimal onClick={onClick} text={label} />,
44+
currentPopup,
45+
);
46+
};

0 commit comments

Comments
 (0)