From 898e3f5e3ac3fd6416d44166d0519b055ea36789 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Oct 2025 14:23:50 -0400 Subject: [PATCH 01/17] current prog --- apps/roam/src/components/CreateNodeDialog.tsx | 10 + apps/roam/src/components/ModifyNodeDialog.tsx | 528 ++++++++++++++++++ apps/roam/src/utils/formatUtils.ts | 63 ++- apps/roam/src/utils/renderNodeTagPopup.tsx | 12 +- 4 files changed, 584 insertions(+), 29 deletions(-) create mode 100644 apps/roam/src/components/ModifyNodeDialog.tsx diff --git a/apps/roam/src/components/CreateNodeDialog.tsx b/apps/roam/src/components/CreateNodeDialog.tsx index 4211f7c56..2d7ed9532 100644 --- a/apps/roam/src/components/CreateNodeDialog.tsx +++ b/apps/roam/src/components/CreateNodeDialog.tsx @@ -1,3 +1,13 @@ +/** + * @deprecated This component is deprecated and will be removed in a future version. + * Please use ModifyNodeDialog instead for a unified create/edit experience. + * + * Migration guide: + * - Replace `renderCreateNodeDialog` with `renderModifyNodeDialog` + * - Map props: mode: "create", nodeType: defaultNodeTypeUid, content: initialTitle + * - See ModifyNodeDialog.tsx for full API + */ + import React, { useEffect, useRef, useState } from "react"; import { Dialog, Classes, InputGroup, Label, Button } from "@blueprintjs/core"; import renderOverlay from "roamjs-components/util/renderOverlay"; diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx new file mode 100644 index 000000000..25025b591 --- /dev/null +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -0,0 +1,528 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { + useRef, + useState, + useMemo, + useEffect, + useCallback, +} from "react"; +import { + Button, + Classes, + Dialog, + Intent, + Spinner, + SpinnerSize, + Label, +} from "@blueprintjs/core"; +import fuzzy from "fuzzy"; +import { OnloadArgs } from "roamjs-components/types"; +import renderOverlay, { + RoamOverlayProps, +} from "roamjs-components/util/renderOverlay"; +import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; +import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; +import { render as renderToast } from "roamjs-components/components/Toast"; +import updateBlock from "roamjs-components/writes/updateBlock"; +import createBlock from "roamjs-components/writes/createBlock"; +import fireQuery from "~/utils/fireQuery"; +import createDiscourseNode from "~/utils/createDiscourseNode"; +import getDiscourseNodes, { + excludeDefaultNodes, +} from "~/utils/getDiscourseNodes"; +import { getNewDiscourseNodeText } from "~/utils/formatUtils"; +import { Result } from "~/utils/types"; +import { DiscourseContextType } from "./canvas/Tldraw"; + +type ReferencedNode = { + name: string; + nodeType: string; + value?: string; +}; + +type ModifyNodeDialogProps = { + mode: "create" | "edit"; + nodeType: string; + content: string; + uid?: string; + referencedNode?: ReferencedNode | null; + onSuccess: (result: { + text: string; + uid: string; + action: string; + newPageUid?: string; + }) => Promise; + onClose: () => void; + extensionAPI: OnloadArgs["extensionAPI"]; + sourceBlockUid?: string; + discourseContext?: DiscourseContextType; +}; + +const ModifyNodeDialog = ({ + isOpen, + onClose, + mode, + nodeType: initialNodeType, + content: initialContent, + uid: initialUid, + onSuccess, + extensionAPI, + sourceBlockUid, + discourseContext, +}: RoamOverlayProps) => { + const containerRef = useRef(null); + const requestIdRef = useRef(0); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const discourseNodes = useMemo( + () => getDiscourseNodes().filter(excludeDefaultNodes), + [], + ); + + const [selectedNodeType, setSelectedNodeType] = useState(() => { + const node = discourseNodes.find((n) => n.type === initialNodeType); + return node || discourseNodes[0]; + }); + + const [options, setOptions] = useState([]); + const [content, setContent] = useState(initialContent); + const [contentUid, setContentUid] = useState(initialUid || ""); + + const [referencedNodeOptions, setReferencedNodeOptions] = useState( + [], + ); + const [referencedNodeValue, setReferencedNodeValue] = useState(""); + const [isAddReferencedNode, setAddReferencedNode] = useState(false); + + const isCreateMode = mode === "create"; + const isEditMode = mode === "edit"; + + // Get node format and referenced node info + const nodeFormat = useMemo(() => { + if (discourseContext) { + return discourseContext.nodes[selectedNodeType.type]?.format || ""; + } + return selectedNodeType.format || ""; + }, [selectedNodeType, discourseContext]); + + const referencedNode = useMemo(() => { + const regex = /{([\w\d-]*)}/g; + const matches = [...nodeFormat.matchAll(regex)]; + + for (const match of matches) { + const val = match[1]; + if (val.toLowerCase() === "content") continue; + if (val.toLowerCase() === "context") continue; + + const allNodes = discourseContext + ? Object.values(discourseContext.nodes) + : discourseNodes; + + const refNode = allNodes.find(({ text }) => + new RegExp(text, "i").test(val), + ); + + if (refNode) { + return { + name: refNode.text, + nodeType: refNode.type, + }; + } + } + + return null; + }, [nodeFormat, discourseNodes, discourseContext]); + + // Fetch options for main content autocomplete + useEffect(() => { + let alive = true; + const req = ++requestIdRef.current; + setIsLoading(true); + + const fetchOptions = async () => { + try { + if (selectedNodeType) { + const conditionUid = window.roamAlphaAPI.util.generateUID(); + const results = await fireQuery({ + returnNode: "node", + selections: [], + conditions: [ + { + source: "node", + relation: "is a", + target: selectedNodeType.type, + uid: conditionUid, + type: "clause", + }, + ], + }); + if (requestIdRef.current === req && alive) setOptions(results); + } + } catch (error) { + if (requestIdRef.current === req && alive) { + console.error("Error fetching content options:", error); + } + } finally { + if (requestIdRef.current === req && alive) setIsLoading(false); + } + }; + + void fetchOptions(); + return () => { + alive = false; + }; + }, [selectedNodeType]); + + // Fetch options for referenced node autocomplete + useEffect(() => { + if (!referencedNode) return; + + let alive = true; + const req = ++requestIdRef.current; + + const fetchReferencedOptions = async () => { + try { + const conditionUid = window.roamAlphaAPI.util.generateUID(); + const results = await fireQuery({ + returnNode: "node", + selections: [], + conditions: [ + { + source: "node", + relation: "is a", + target: referencedNode.nodeType, + uid: conditionUid, + type: "clause", + }, + ], + }); + if (requestIdRef.current === req && alive) { + setReferencedNodeOptions(results); + } + } catch (error) { + if (requestIdRef.current === req && alive) { + console.error("Error fetching referenced node options:", error); + } + } + }; + + void fetchReferencedOptions(); + return () => { + alive = false; + }; + }, [referencedNode]); + + const setValue = useCallback( + (r: Result) => { + const generatedUid = initialUid || window.roamAlphaAPI.util.generateUID(); + + if (isCreateMode && r.uid === generatedUid) { + // Creating new node with format + const pageName = nodeFormat.replace(/{([\w\d-]*)}/g, (_, val: string) => { + if (/content/i.test(val)) return r.text; + if ( + referencedNode && + new RegExp(referencedNode.name, "i").test(val) && + isAddReferencedNode + ) + return referencedNodeValue; + return ""; + }); + setContent(pageName); + } else { + setContent(r.text); + } + setContentUid(r.uid); + }, + [ + initialUid, + isCreateMode, + nodeFormat, + referencedNode, + isAddReferencedNode, + referencedNodeValue, + ], + ); + + const setValueFromReferencedNode = useCallback( + (r: Result) => { + if (!referencedNode) return; + + if (isEditMode) { + // Hack for default shipped EVD format + if (content.endsWith(" - ")) { + setContent(`${content}[[${r.text}]]`); + } else if (content.endsWith(" -")) { + setContent(`${content} [[${r.text}]]`); + } else { + setContent(`${content} - [[${r.text}]]`); + } + } else { + const pageName = nodeFormat.replace(/{([\w\d-]*)}/g, (_, val: string) => { + if (/content/i.test(val)) return content; + if (new RegExp(referencedNode.name, "i").test(val)) + return `[[${r.text}]]`; + return ""; + }); + setContent(pageName); + } + setReferencedNodeValue(r.text); + }, + [content, referencedNode, nodeFormat, isEditMode], + ); + + const onNewItem = useCallback( + (text: string) => ({ + text, + uid: initialUid || window.roamAlphaAPI.util.generateUID(), + }), + [initialUid], + ); + + const itemToQuery = useCallback((result?: Result) => result?.text || "", []); + + const filterOptions = useCallback( + (o: Result[], q: string) => + fuzzy + .filter(q, o, { extract: itemToQuery }) + .map((f) => f.original) + .filter((f): f is Result => !!f), + [itemToQuery], + ); + + const onSubmit = async () => { + if (!content.trim()) return; + + setLoading(true); + setError(""); + + try { + const action = isCreateMode ? "creating" : "editing"; + + if (action === "creating") { + const formattedTitle = await getNewDiscourseNodeText({ + text: content.trim(), + nodeType: selectedNodeType.type, + blockUid: sourceBlockUid, + }); + + if (!formattedTitle) { + setLoading(false); + return; + } + + // Create new discourse node + const newPageUid = await createDiscourseNode({ + text: formattedTitle, + configPageUid: selectedNodeType.type, + extensionAPI, + }); + + // Handle source block update if needed + if (sourceBlockUid) { + const pageRef = `[[${formattedTitle}]]`; + await updateBlock({ + uid: sourceBlockUid, + text: pageRef, + }); + if (initialContent && initialContent.trim()) { + await createBlock({ + parentUid: sourceBlockUid, + order: 0, + node: { + text: initialContent, + }, + }); + } + } + + renderToast({ + id: `discourse-node-created-${Date.now()}`, + intent: "success", + timeout: 10000, + content: ( + + Created node{" "} + { + void (async () => { + if (event.shiftKey) { + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { + // @ts-expect-error TODO: fix this + "block-uid": newPageUid, + type: "outline", + }, + }); + } else { + await window.roamAlphaAPI.ui.mainWindow.openPage({ + page: { uid: newPageUid }, + }); + } + })(); + }} + > + [[{formattedTitle}]] + + + ), + }); + + await onSuccess({ + text: formattedTitle, + uid: contentUid, + action, + newPageUid, + }); + } else { + // Setting to existing or editing + await onSuccess({ + text: content, + uid: contentUid, + action, + }); + } + + onClose(); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }; + + const onCancelClick = useCallback(() => { + onClose(); + }, [onClose]); + + // Auto-focus handling for referenced node input + const inputDivRef = useRef(null); + useEffect(() => { + if (isAddReferencedNode && inputDivRef.current) { + const inputElement = + inputDivRef.current.getElementsByTagName("textarea")[0]; + if (inputElement) inputElement.focus(); + } + }, [isAddReferencedNode]); + + return ( + +
+ {/* Node Type Selector */} +
+ +
+ + {/* Content Input */} +
+ +
+ void onSubmit()} + options={options} + multiline + autoFocus + onNewItem={onNewItem} + itemToQuery={itemToQuery} + filterOptions={filterOptions} + disabled={isLoading} + placeholder={ + isLoading + ? "Loading ..." + : `Enter ${selectedNodeType.text.toLowerCase()} content ...` + } + maxItemsDisplayed={100} + /> +
+
+ + {/* Referenced Node Section */} + {referencedNode && ( +
+ +
+ +
+
+ )} +
+ +
+
+
+
+
+ ); +}; + +export const renderModifyNodeDialog = (props: ModifyNodeDialogProps) => + renderOverlay({ + Overlay: ModifyNodeDialog, + props, + }); + +export default ModifyNodeDialog; + diff --git a/apps/roam/src/utils/formatUtils.ts b/apps/roam/src/utils/formatUtils.ts index 580ba501a..d90330fa8 100644 --- a/apps/roam/src/utils/formatUtils.ts +++ b/apps/roam/src/utils/formatUtils.ts @@ -1,7 +1,7 @@ // To be removed when format is migrated to specification // https://github.com/RoamJS/query-builder/issues/189 -import { PullBlock } from "roamjs-components/types"; +import { OnloadArgs, PullBlock } from "roamjs-components/types"; import getDiscourseNodes, { DiscourseNode } from "./getDiscourseNodes"; import compileDatalog from "./compileDatalog"; import discourseNodeFormatToDatalog from "./discourseNodeFormatToDatalog"; @@ -12,6 +12,7 @@ import { QBClause, Result } from "./types"; import findDiscourseNode from "./findDiscourseNode"; import extractTag from "roamjs-components/util/extractTag"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog"; type FormDialogProps = Parameters[0]; const renderFormDialog = createOverlayRender( @@ -34,31 +35,43 @@ export const getNewDiscourseNodeText = async ({ newText = await new Promise((resolve) => { const nodeName = discourseNodes.find((n) => n.type === nodeType)?.text || "Discourse"; - renderFormDialog({ - title: `Create ${nodeName} Node`, - fields: { - textField: { - type: "text", - label: `Create ${nodeName} Node`, - }, - }, - onSubmit: (data: Record) => { - const textValue = data.textField as string; - if (textValue?.trim()) { - resolve(textValue); - } else { - renderToast({ - content: "Text field cannot be empty.", - id: "roamjs-create-discourse-node-dialog-error", - intent: "warning", - }); - return false; - } - }, - onClose: () => { - resolve(""); + // renderFormDialog({ + // title: `Create ${nodeName} Node`, + // fields: { + // textField: { + // type: "text", + // label: `Create ${nodeName} Node`, + // }, + // }, + // onSubmit: (data: Record) => { + // const textValue = data.textField as string; + // if (textValue?.trim()) { + // resolve(textValue); + // } else { + // renderToast({ + // content: "Text field cannot be empty.", + // id: "roamjs-create-discourse-node-dialog-error", + // intent: "warning", + // }); + // return false; + // } + // }, + // onClose: () => { + // resolve(""); + // }, + // isOpen: true, + // }); + renderModifyNodeDialog({ + mode: "create", + nodeType: nodeType, + content: text, + onSuccess: async () => { + // Success is handled by the dialog itself }, - isOpen: true, + onClose: () => {}, + extensionAPI: + window.roamAlphaAPI as unknown as OnloadArgs["extensionAPI"], + sourceBlockUid: blockUid, }); const setupButtonControl = () => { diff --git a/apps/roam/src/utils/renderNodeTagPopup.tsx b/apps/roam/src/utils/renderNodeTagPopup.tsx index d8f2cdfd7..9e98b7089 100644 --- a/apps/roam/src/utils/renderNodeTagPopup.tsx +++ b/apps/roam/src/utils/renderNodeTagPopup.tsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom"; import { Button, Popover, Position } from "@blueprintjs/core"; -import { renderCreateNodeDialog } from "~/components/CreateNodeDialog"; +import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog"; import { OnloadArgs } from "roamjs-components/types"; import getUids from "roamjs-components/dom/getUids"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; @@ -49,12 +49,16 @@ export const renderNodeTagPopupButton = ( minimal outlined onClick={() => { - renderCreateNodeDialog({ + renderModifyNodeDialog({ + mode: "create", + nodeType: matchedNode.type, + content: cleanedBlockText, + onSuccess: async () => { + // Success is handled by the dialog itself + }, onClose: () => {}, - defaultNodeTypeUid: matchedNode.type, extensionAPI, sourceBlockUid: blockUid, - initialTitle: cleanedBlockText, }); }} text={`Create ${matchedNode.text}`} From 1ace767224f44aa8733fbbe6d639f96088091ce0 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Oct 2025 08:48:44 -0400 Subject: [PATCH 02/17] cur progress --- apps/roam/src/components/ModifyNodeDialog.tsx | 170 ++++++++++-------- apps/roam/src/utils/formatUtils.ts | 20 ++- 2 files changed, 110 insertions(+), 80 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 25025b591..1e665cdac 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -40,7 +40,7 @@ type ReferencedNode = { value?: string; }; -type ModifyNodeDialogProps = { +export type ModifyNodeDialogProps = { mode: "create" | "edit"; nodeType: string; content: string; @@ -71,35 +71,37 @@ const ModifyNodeDialog = ({ discourseContext, }: RoamOverlayProps) => { const containerRef = useRef(null); - const requestIdRef = useRef(0); - + const contentRequestIdRef = useRef(0); + const referencedNodeRequestIdRef = useRef(0); + const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [isLoading, setIsLoading] = useState(false); - + const [isReferencedNodeLoading, setIsReferencedNodeLoading] = useState(false); + const discourseNodes = useMemo( () => getDiscourseNodes().filter(excludeDefaultNodes), [], ); - + const [selectedNodeType, setSelectedNodeType] = useState(() => { const node = discourseNodes.find((n) => n.type === initialNodeType); return node || discourseNodes[0]; }); - + const [options, setOptions] = useState([]); const [content, setContent] = useState(initialContent); const [contentUid, setContentUid] = useState(initialUid || ""); - + const [referencedNodeOptions, setReferencedNodeOptions] = useState( [], ); const [referencedNodeValue, setReferencedNodeValue] = useState(""); const [isAddReferencedNode, setAddReferencedNode] = useState(false); - + const isCreateMode = mode === "create"; const isEditMode = mode === "edit"; - + // Get node format and referenced node info const nodeFormat = useMemo(() => { if (discourseContext) { @@ -107,24 +109,24 @@ const ModifyNodeDialog = ({ } return selectedNodeType.format || ""; }, [selectedNodeType, discourseContext]); - + const referencedNode = useMemo(() => { const regex = /{([\w\d-]*)}/g; const matches = [...nodeFormat.matchAll(regex)]; - + for (const match of matches) { const val = match[1]; if (val.toLowerCase() === "content") continue; if (val.toLowerCase() === "context") continue; - + const allNodes = discourseContext ? Object.values(discourseContext.nodes) : discourseNodes; - + const refNode = allNodes.find(({ text }) => new RegExp(text, "i").test(val), ); - + if (refNode) { return { name: refNode.text, @@ -132,16 +134,16 @@ const ModifyNodeDialog = ({ }; } } - + return null; }, [nodeFormat, discourseNodes, discourseContext]); - + // Fetch options for main content autocomplete useEffect(() => { let alive = true; - const req = ++requestIdRef.current; + const req = ++contentRequestIdRef.current; setIsLoading(true); - + const fetchOptions = async () => { try { if (selectedNodeType) { @@ -159,32 +161,33 @@ const ModifyNodeDialog = ({ }, ], }); - if (requestIdRef.current === req && alive) setOptions(results); + if (contentRequestIdRef.current === req && alive) setOptions(results); } } catch (error) { - if (requestIdRef.current === req && alive) { + if (contentRequestIdRef.current === req && alive) { console.error("Error fetching content options:", error); } } finally { - if (requestIdRef.current === req && alive) setIsLoading(false); + if (contentRequestIdRef.current === req && alive) setIsLoading(false); } }; - + void fetchOptions(); return () => { alive = false; }; }, [selectedNodeType]); - + // Fetch options for referenced node autocomplete useEffect(() => { if (!referencedNode) return; - + let alive = true; - const req = ++requestIdRef.current; - + const req = ++referencedNodeRequestIdRef.current; + const fetchReferencedOptions = async () => { try { + setIsReferencedNodeLoading(true); const conditionUid = window.roamAlphaAPI.util.generateUID(); const results = await fireQuery({ returnNode: "node", @@ -199,38 +202,44 @@ const ModifyNodeDialog = ({ }, ], }); - if (requestIdRef.current === req && alive) { + if (referencedNodeRequestIdRef.current === req && alive) { setReferencedNodeOptions(results); } } catch (error) { - if (requestIdRef.current === req && alive) { + if (referencedNodeRequestIdRef.current === req && alive) { console.error("Error fetching referenced node options:", error); } + } finally { + if (referencedNodeRequestIdRef.current === req && alive) + setIsReferencedNodeLoading(false); } }; - + void fetchReferencedOptions(); return () => { alive = false; }; }, [referencedNode]); - + const setValue = useCallback( (r: Result) => { const generatedUid = initialUid || window.roamAlphaAPI.util.generateUID(); - + if (isCreateMode && r.uid === generatedUid) { // Creating new node with format - const pageName = nodeFormat.replace(/{([\w\d-]*)}/g, (_, val: string) => { - if (/content/i.test(val)) return r.text; - if ( - referencedNode && - new RegExp(referencedNode.name, "i").test(val) && - isAddReferencedNode - ) - return referencedNodeValue; - return ""; - }); + const pageName = nodeFormat.replace( + /{([\w\d-]*)}/g, + (_, val: string) => { + if (/content/i.test(val)) return r.text; + if ( + referencedNode && + new RegExp(referencedNode.name, "i").test(val) && + isAddReferencedNode + ) + return referencedNodeValue; + return ""; + }, + ); setContent(pageName); } else { setContent(r.text); @@ -246,11 +255,11 @@ const ModifyNodeDialog = ({ referencedNodeValue, ], ); - + const setValueFromReferencedNode = useCallback( (r: Result) => { if (!referencedNode) return; - + if (isEditMode) { // Hack for default shipped EVD format if (content.endsWith(" - ")) { @@ -261,19 +270,22 @@ const ModifyNodeDialog = ({ setContent(`${content} - [[${r.text}]]`); } } else { - const pageName = nodeFormat.replace(/{([\w\d-]*)}/g, (_, val: string) => { - if (/content/i.test(val)) return content; - if (new RegExp(referencedNode.name, "i").test(val)) - return `[[${r.text}]]`; - return ""; - }); + const pageName = nodeFormat.replace( + /{([\w\d-]*)}/g, + (_, val: string) => { + if (/content/i.test(val)) return content; + if (new RegExp(referencedNode.name, "i").test(val)) + return `[[${r.text}]]`; + return ""; + }, + ); setContent(pageName); } setReferencedNodeValue(r.text); }, [content, referencedNode, nodeFormat, isEditMode], ); - + const onNewItem = useCallback( (text: string) => ({ text, @@ -281,9 +293,9 @@ const ModifyNodeDialog = ({ }), [initialUid], ); - + const itemToQuery = useCallback((result?: Result) => result?.text || "", []); - + const filterOptions = useCallback( (o: Result[], q: string) => fuzzy @@ -292,35 +304,35 @@ const ModifyNodeDialog = ({ .filter((f): f is Result => !!f), [itemToQuery], ); - + const onSubmit = async () => { if (!content.trim()) return; - + setLoading(true); setError(""); - + try { const action = isCreateMode ? "creating" : "editing"; - + if (action === "creating") { const formattedTitle = await getNewDiscourseNodeText({ text: content.trim(), nodeType: selectedNodeType.type, blockUid: sourceBlockUid, }); - + if (!formattedTitle) { setLoading(false); return; } - + // Create new discourse node const newPageUid = await createDiscourseNode({ text: formattedTitle, configPageUid: selectedNodeType.type, extensionAPI, }); - + // Handle source block update if needed if (sourceBlockUid) { const pageRef = `[[${formattedTitle}]]`; @@ -338,7 +350,7 @@ const ModifyNodeDialog = ({ }); } } - + renderToast({ id: `discourse-node-created-${Date.now()}`, intent: "success", @@ -371,7 +383,7 @@ const ModifyNodeDialog = ({ ), }); - + await onSuccess({ text: formattedTitle, uid: contentUid, @@ -386,7 +398,7 @@ const ModifyNodeDialog = ({ action, }); } - + onClose(); } catch (e) { setError((e as Error).message); @@ -394,11 +406,11 @@ const ModifyNodeDialog = ({ setLoading(false); } }; - + const onCancelClick = useCallback(() => { onClose(); }, [onClose]); - + // Auto-focus handling for referenced node input const inputDivRef = useRef(null); useEffect(() => { @@ -408,7 +420,7 @@ const ModifyNodeDialog = ({ if (inputElement) inputElement.focus(); } }, [isAddReferencedNode]); - + return ( -
+
{/* Node Type Selector */}
- + {/* Content Input */} -
+
- + {/* Referenced Node Section */} {referencedNode && ( -
+
{ + // Prevent default behavior, don't submit the form + // Just keep the dialog open + }} placeholder={ - isLoading ? "..." : `Enter a ${referencedNode.name} ...` + isReferencedNodeLoading + ? "..." + : `Enter a ${referencedNode.name} ...` } maxItemsDisplayed={100} />
- )} + )}
- +
[0]; -const renderFormDialog = createOverlayRender( +const renderFormDialog = createOverlayRender( "form-dialog", - FormDialog, + ModifyNodeDialog, ); export const getNewDiscourseNodeText = async ({ @@ -61,17 +68,16 @@ export const getNewDiscourseNodeText = async ({ // }, // isOpen: true, // }); - renderModifyNodeDialog({ + renderFormDialog({ mode: "create", nodeType: nodeType, content: text, onSuccess: async () => { - // Success is handled by the dialog itself + resolve(text); }, onClose: () => {}, extensionAPI: window.roamAlphaAPI as unknown as OnloadArgs["extensionAPI"], - sourceBlockUid: blockUid, }); const setupButtonControl = () => { From c82a8316a813212bd54f24058b1182550af41d1a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Nov 2025 17:16:34 -0800 Subject: [PATCH 03/17] curr progress --- .../components/LockableAutocompleteInput.tsx | 130 ++++++++++++++++++ apps/roam/src/components/ModifyNodeDialog.tsx | 91 ++++++++---- 2 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 apps/roam/src/components/LockableAutocompleteInput.tsx diff --git a/apps/roam/src/components/LockableAutocompleteInput.tsx b/apps/roam/src/components/LockableAutocompleteInput.tsx new file mode 100644 index 000000000..1b9d3bcd5 --- /dev/null +++ b/apps/roam/src/components/LockableAutocompleteInput.tsx @@ -0,0 +1,130 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { Button, Classes, Icon } from "@blueprintjs/core"; +import AutocompleteInput, { + AutocompleteInputProps, +} from "roamjs-components/components/AutocompleteInput"; +import { Result } from "~/utils/types"; + +type LockableAutocompleteInputProps = Omit< + AutocompleteInputProps, + "value" | "setValue" | "onConfirm" +> & { + value?: T; + setValue: (q: T) => void; + onConfirm?: () => void; + onLockedChange?: (isLocked: boolean) => void; +}; + +const LockableAutocompleteInput = ({ + value, + setValue, + onConfirm, + onLockedChange, + options = [], + ...autocompleteProps +}: LockableAutocompleteInputProps) => { + const [isLocked, setIsLocked] = useState(false); + const [lockedValue, setLockedValue] = useState(undefined); + const isInternalUpdateRef = useRef(false); + + // Check if value is from options (existing node) vs new node + const isValueFromOptions = useCallback( + (val: T | undefined): boolean => { + if (!val || !val.uid || !val.text) return false; + return options.some((opt) => opt.uid === val.uid && opt.text === val.text); + }, + [options], + ); + + // Initialize locked state from initial value + useEffect(() => { + if (value && isValueFromOptions(value)) { + setLockedValue(value); + setIsLocked(true); + onLockedChange?.(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run on mount + + // Update locked state when value changes externally + useEffect(() => { + // Skip if this update was triggered internally + if (isInternalUpdateRef.current) { + isInternalUpdateRef.current = false; + return; + } + + // Lock if value matches an option and we're not already locked + if (!isLocked && value && isValueFromOptions(value)) { + setLockedValue(value); + setIsLocked(true); + onLockedChange?.(true); + } else if (isLocked && (!value || !value.text || !isValueFromOptions(value))) { + // Unlock if value is cleared or no longer matches an option + setIsLocked(false); + setLockedValue(undefined); + onLockedChange?.(false); + } + }, [value, isLocked, isValueFromOptions, onLockedChange]); + + const handleSetValue = useCallback( + (q: T) => { + isInternalUpdateRef.current = true; + setValue(q); + // Lock when selecting from options + if (isValueFromOptions(q)) { + setLockedValue(q); + setIsLocked(true); + onLockedChange?.(true); + } else { + // Unlock if selecting a new item + setIsLocked(false); + setLockedValue(undefined); + onLockedChange?.(false); + } + }, + [setValue, isValueFromOptions, onLockedChange], + ); + + const handleClear = useCallback(() => { + isInternalUpdateRef.current = true; + setIsLocked(false); + setLockedValue(undefined); + setValue({ text: "", uid: "" } as T); + onLockedChange?.(false); + }, [setValue, onLockedChange]); + + if (isLocked && lockedValue) { + return ( +
+
+ + + {lockedValue.text} + +
+
+ ); + } + + return ( + + ); +} + +export default LockableAutocompleteInput; + diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 1e665cdac..84a63043b 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -20,7 +20,7 @@ import { OnloadArgs } from "roamjs-components/types"; import renderOverlay, { RoamOverlayProps, } from "roamjs-components/util/renderOverlay"; -import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; +import LockableAutocompleteInput from "./LockableAutocompleteInput"; import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; import { render as renderToast } from "roamjs-components/components/Toast"; import updateBlock from "roamjs-components/writes/updateBlock"; @@ -92,11 +92,14 @@ const ModifyNodeDialog = ({ const [options, setOptions] = useState([]); const [content, setContent] = useState(initialContent); const [contentUid, setContentUid] = useState(initialUid || ""); + const [isContentLocked, setIsContentLocked] = useState(false); const [referencedNodeOptions, setReferencedNodeOptions] = useState( [], ); const [referencedNodeValue, setReferencedNodeValue] = useState(""); + const [referencedNodeUid, setReferencedNodeUid] = useState(""); + const [isReferencedNodeLocked, setIsReferencedNodeLocked] = useState(false); const [isAddReferencedNode, setAddReferencedNode] = useState(false); const isCreateMode = mode === "create"; @@ -260,30 +263,34 @@ const ModifyNodeDialog = ({ (r: Result) => { if (!referencedNode) return; - if (isEditMode) { - // Hack for default shipped EVD format - if (content.endsWith(" - ")) { - setContent(`${content}[[${r.text}]]`); - } else if (content.endsWith(" -")) { - setContent(`${content} [[${r.text}]]`); + // Only update content if not locked and we have text + if (!isReferencedNodeLocked && r.text) { + if (isEditMode) { + // Hack for default shipped EVD format + if (content.endsWith(" - ")) { + setContent(`${content}[[${r.text}]]`); + } else if (content.endsWith(" -")) { + setContent(`${content} [[${r.text}]]`); + } else { + setContent(`${content} - [[${r.text}]]`); + } } else { - setContent(`${content} - [[${r.text}]]`); + const pageName = nodeFormat.replace( + /{([\w\d-]*)}/g, + (_, val: string) => { + if (/content/i.test(val)) return content; + if (new RegExp(referencedNode.name, "i").test(val)) + return `[[${r.text}]]`; + return ""; + }, + ); + setContent(pageName); } - } else { - const pageName = nodeFormat.replace( - /{([\w\d-]*)}/g, - (_, val: string) => { - if (/content/i.test(val)) return content; - if (new RegExp(referencedNode.name, "i").test(val)) - return `[[${r.text}]]`; - return ""; - }, - ); - setContent(pageName); } setReferencedNodeValue(r.text); + setReferencedNodeUid(r.uid); }, - [content, referencedNode, nodeFormat, isEditMode], + [content, referencedNode, nodeFormat, isEditMode, isReferencedNodeLocked], ); const onNewItem = useCallback( @@ -294,6 +301,14 @@ const ModifyNodeDialog = ({ [initialUid], ); + const onNewReferencedNodeItem = useCallback( + (text: string) => ({ + text, + uid: window.roamAlphaAPI.util.generateUID(), + }), + [], + ); + const itemToQuery = useCallback((result?: Result) => result?.text || "", []); const filterOptions = useCallback( @@ -315,6 +330,27 @@ const ModifyNodeDialog = ({ const action = isCreateMode ? "creating" : "editing"; if (action === "creating") { + // If content is locked (user selected existing node), just insert it + if (isContentLocked && contentUid) { + if (sourceBlockUid) { + const pageRef = `[[${content}]]`; + await updateBlock({ + uid: sourceBlockUid, + text: pageRef, + }); + } + + await onSuccess({ + text: content, + uid: contentUid, + action, + }); + + onClose(); + return; + } + + // Otherwise, format and create new node const formattedTitle = await getNewDiscourseNodeText({ text: content.trim(), nodeType: selectedNodeType.type, @@ -460,7 +496,7 @@ const ModifyNodeDialog = ({
- void onSubmit()} @@ -477,6 +513,7 @@ const ModifyNodeDialog = ({ : `Enter ${selectedNodeType.text.toLowerCase()} content ...` } maxItemsDisplayed={100} + onLockedChange={setIsContentLocked} />
@@ -489,28 +526,26 @@ const ModifyNodeDialog = ({ >
- { - // Prevent default behavior, don't submit the form - // Just keep the dialog open - }} placeholder={ isReferencedNodeLoading ? "..." : `Enter a ${referencedNode.name} ...` } + disabled={isReferencedNodeLoading} maxItemsDisplayed={100} + onLockedChange={setIsReferencedNodeLocked} />
From 0e150205db9a582e9f1dfad5d2633b5272e56cab Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 6 Nov 2025 16:48:59 -0800 Subject: [PATCH 04/17] replaced LabelDialog successfully --- .../components/LockableAutocompleteInput.tsx | 110 ++++++------ apps/roam/src/components/ModifyNodeDialog.tsx | 53 ++++-- .../components/canvas/DiscourseNodeUtil.tsx | 159 ++++++++++-------- 3 files changed, 175 insertions(+), 147 deletions(-) diff --git a/apps/roam/src/components/LockableAutocompleteInput.tsx b/apps/roam/src/components/LockableAutocompleteInput.tsx index 1b9d3bcd5..0d6d85d35 100644 --- a/apps/roam/src/components/LockableAutocompleteInput.tsx +++ b/apps/roam/src/components/LockableAutocompleteInput.tsx @@ -1,5 +1,5 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { Button, Classes, Icon } from "@blueprintjs/core"; +import React, { useState, useCallback } from "react"; +import { Button, Icon, TextArea } from "@blueprintjs/core"; import AutocompleteInput, { AutocompleteInputProps, } from "roamjs-components/components/AutocompleteInput"; @@ -13,6 +13,7 @@ type LockableAutocompleteInputProps = Omit< setValue: (q: T) => void; onConfirm?: () => void; onLockedChange?: (isLocked: boolean) => void; + mode: "create" | "edit"; }; const LockableAutocompleteInput = ({ @@ -20,85 +21,74 @@ const LockableAutocompleteInput = ({ setValue, onConfirm, onLockedChange, + mode, options = [], ...autocompleteProps }: LockableAutocompleteInputProps) => { const [isLocked, setIsLocked] = useState(false); const [lockedValue, setLockedValue] = useState(undefined); - const isInternalUpdateRef = useRef(false); - - // Check if value is from options (existing node) vs new node - const isValueFromOptions = useCallback( - (val: T | undefined): boolean => { - if (!val || !val.uid || !val.text) return false; - return options.some((opt) => opt.uid === val.uid && opt.text === val.text); - }, - [options], - ); - - // Initialize locked state from initial value - useEffect(() => { - if (value && isValueFromOptions(value)) { - setLockedValue(value); - setIsLocked(true); - onLockedChange?.(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only run on mount - - // Update locked state when value changes externally - useEffect(() => { - // Skip if this update was triggered internally - if (isInternalUpdateRef.current) { - isInternalUpdateRef.current = false; - return; - } - - // Lock if value matches an option and we're not already locked - if (!isLocked && value && isValueFromOptions(value)) { - setLockedValue(value); - setIsLocked(true); - onLockedChange?.(true); - } else if (isLocked && (!value || !value.text || !isValueFromOptions(value))) { - // Unlock if value is cleared or no longer matches an option - setIsLocked(false); - setLockedValue(undefined); - onLockedChange?.(false); - } - }, [value, isLocked, isValueFromOptions, onLockedChange]); const handleSetValue = useCallback( (q: T) => { - isInternalUpdateRef.current = true; - setValue(q); - // Lock when selecting from options - if (isValueFromOptions(q)) { + // In create mode, when user selects an option from suggestions, lock it + if ( + mode === "create" && + q.text && + options.some((opt) => opt.text === q.text) + ) { setLockedValue(q); setIsLocked(true); onLockedChange?.(true); - } else { - // Unlock if selecting a new item - setIsLocked(false); - setLockedValue(undefined); - onLockedChange?.(false); + setValue(q); + return; } + + // Otherwise, just update the value (user is typing) + setValue(q); }, - [setValue, isValueFromOptions, onLockedChange], + [setValue, onLockedChange, mode, options], ); const handleClear = useCallback(() => { - isInternalUpdateRef.current = true; setIsLocked(false); setLockedValue(undefined); setValue({ text: "", uid: "" } as T); onLockedChange?.(false); }, [setValue, onLockedChange]); + if (mode === "edit") { + return ( +