From 962f5ae3bf5513d84a3bc4ccb9440eb1c61e3fcc Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Mon, 12 May 2025 11:57:56 -0400 Subject: [PATCH 1/5] [ENG-259] Create Observer and UI pattern (#138) * create event listener for @ key * fix ui * address PR comments * cur progress * current progress * tested yayyy * add the checkbox UI * fix focus issue * rm logs * address PR comments * address PR comments --- .../components/DiscourseNodeSearchMenu.tsx | 417 ++++++++++++++++++ apps/roam/src/index.ts | 13 +- .../utils/initializeObserversAndListeners.ts | 44 +- 3 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 apps/roam/src/components/DiscourseNodeSearchMenu.tsx diff --git a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx new file mode 100644 index 000000000..83206c262 --- /dev/null +++ b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx @@ -0,0 +1,417 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Menu, + MenuItem, + Popover, + Position, + Checkbox, + Button, + Icon, +} from "@blueprintjs/core"; +import ReactDOM from "react-dom"; +import getUids from "roamjs-components/dom/getUids"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; +import updateBlock from "roamjs-components/writes/updateBlock"; +import posthog from "posthog-js"; +import { getCoordsFromTextarea } from "roamjs-components/components/CursorMenu"; + +type Props = { + textarea: HTMLTextAreaElement; + triggerPosition: number; + onClose: () => void; +}; + +type DiscourseType = { + type: string; + title: string; + items: { id: string; text: string }[]; +}; + +// Hardcoded discourse types for testing +// TODO: replace with actual discourse types +const DISCOURSE_TYPES: DiscourseType[] = [ + { + type: "claims", + title: "Claims", + items: [ + { id: "clm1", text: "[[CLM]] - Claim 1" }, + { id: "clm2", text: "[[CLM]] - Claim 1" }, + ], + }, + { + type: "evidence", + title: "Evidence", + items: [ + { id: "evd1", text: "[[EVD]] - Evidence 1" }, + { id: "evd2", text: "[[EVD]] - Evidence 2" }, + ], + }, + { + type: "results", + title: "Results", + items: [ + { id: "res1", text: "[[RES]] - Result 1" }, + { id: "res2", text: "[[RES]] - Result 1" }, + ], + }, +]; + +const waitForBlock = ( + uid: string, + text: string, + retries = 0, + maxRetries = 30, +): Promise => + getTextByBlockUid(uid) === text + ? Promise.resolve() + : retries >= maxRetries + ? Promise.resolve() + : new Promise((resolve) => + setTimeout( + () => resolve(waitForBlock(uid, text, retries + 1, maxRetries)), + 10, + ), + ); + +const NodeSearchMenu = ({ + onClose, + textarea, + triggerPosition, +}: { onClose: () => void } & Props) => { + const [activeIndex, setActiveIndex] = useState(0); + const [searchTerm, setSearchTerm] = useState(""); + const [checkedTypes, setCheckedTypes] = useState>( + DISCOURSE_TYPES.reduce((acc, type) => ({ ...acc, [type.type]: true }), {}), + ); + const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); + const menuRef = useRef(null); + const { ["block-uid"]: blockUid, ["window-id"]: windowId } = useMemo( + () => + window.roamAlphaAPI.ui.getFocusedBlock() || { + "block-uid": "", + "window-id": "", + }, + [], + ); + + const filteredTypes = useMemo(() => { + const typesToShow = DISCOURSE_TYPES.filter( + (type) => checkedTypes[type.type], + ); + + if (!searchTerm.trim()) return typesToShow; + + return typesToShow + .map((type) => ({ + ...type, + items: type.items.filter((item) => + item.text.toLowerCase().includes(searchTerm.toLowerCase()), + ), + })) + .filter((type) => type.items.length > 0); + }, [searchTerm, checkedTypes]); + + const allItems = useMemo(() => { + const items: { + typeIndex: number; + itemIndex: number; + item: { id: string; text: string }; + }[] = []; + + filteredTypes.forEach((type, typeIndex) => { + type.items.forEach((item, itemIndex) => { + items.push({ typeIndex, itemIndex, item }); + }); + }); + + return items; + }, [filteredTypes]); + + const onSelect = useCallback( + (item: { id: string; text: string }) => { + waitForBlock(blockUid, textarea.value).then(() => { + onClose(); + + setTimeout(() => { + const originalText = getTextByBlockUid(blockUid); + + const prefix = originalText.substring(0, triggerPosition); + const suffix = originalText.substring(textarea.selectionStart); + const pageRef = `[[${item.text}]]`; + + const newText = `${prefix}${pageRef}${suffix}`; + updateBlock({ uid: blockUid, text: newText }).then(() => { + const newCursorPosition = triggerPosition + pageRef.length; + + if (window.roamAlphaAPI.ui.setBlockFocusAndSelection) { + window.roamAlphaAPI.ui.setBlockFocusAndSelection({ + location: { + "block-uid": blockUid, + "window-id": windowId, + }, + selection: { start: newCursorPosition }, + }); + } else { + setTimeout(() => { + const textareaElements = document.querySelectorAll("textarea"); + for (const el of textareaElements) { + if ( + getUids(el as HTMLTextAreaElement).blockUid === blockUid + ) { + (el as HTMLTextAreaElement).focus(); + (el as HTMLTextAreaElement).setSelectionRange( + newCursorPosition, + newCursorPosition, + ); + break; + } + } + }, 50); + } + }); + + posthog.capture("Discourse Node: Selected from Search Menu", { + id: item.id, + text: item.text, + }); + }, 10); + }); + }, + [blockUid, onClose, searchTerm, textarea], + ); + + const handleTextAreaInput = useCallback(() => { + const atTriggerRegex = /@(.*)$/; + const textBeforeCursor = textarea.value.substring( + triggerPosition, + textarea.selectionStart, + ); + const match = atTriggerRegex.exec(textBeforeCursor); + if (match) { + setSearchTerm(match[1]); + } else { + onClose(); + return; + } + }, [textarea, onClose, setSearchTerm, triggerPosition]); + + const keydownListener = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + setActiveIndex((prev) => (prev + 1) % allItems.length); + e.preventDefault(); + e.stopPropagation(); + } else if (e.key === "ArrowUp") { + setActiveIndex( + (prev) => (prev - 1 + allItems.length) % allItems.length, + ); + e.preventDefault(); + e.stopPropagation(); + } else if (e.key === "Enter") { + if (allItems.length > 0) { + e.preventDefault(); + e.stopPropagation(); + onSelect(allItems[activeIndex].item); + } + } else if (e.key === "Escape") { + onClose(); + e.preventDefault(); + e.stopPropagation(); + } + }, + [allItems, setActiveIndex, onSelect, onClose], + ); + + useEffect(() => { + if (activeIndex >= allItems.length) { + setActiveIndex(0); + } + }, [allItems, activeIndex]); + + useEffect(() => { + textarea.addEventListener("input", handleTextAreaInput); + return () => { + textarea.removeEventListener("input", handleTextAreaInput); + }; + }, [handleTextAreaInput, textarea]); + + useEffect(() => { + const listeningEl = !!textarea.closest(".rm-reference-item") + ? textarea.parentElement + : textarea; + listeningEl?.addEventListener("keydown", keydownListener); + return () => { + listeningEl?.removeEventListener("keydown", keydownListener); + }; + }, [keydownListener]); + + useEffect(() => { + setTimeout(() => { + handleTextAreaInput(); + }, 50); + }, [handleTextAreaInput]); + + let currentGlobalIndex = -1; + + const handleTypeCheckChange = useCallback( + (typeKey: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setCheckedTypes((prev) => ({ + ...prev, + [typeKey]: !prev[typeKey], + })); + + setTimeout(() => { + textarea.focus(); + const cursorPos = textarea.selectionStart; + textarea.setSelectionRange(cursorPos, cursorPos); + }, 0); + }, + [textarea], + ); + + const remainFocusOnTextarea = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const toggleFilterMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsFilterMenuOpen((prev) => !prev); + + // Restore focus after toggle + setTimeout(() => { + if (textarea) { + textarea.focus(); + const cursorPos = textarea.selectionStart; + textarea.setSelectionRange(cursorPos, cursorPos); + } + }, 0); + }, + [textarea], + ); + + return ( + } + position={Position.BOTTOM_LEFT} + modifiers={{ + flip: { enabled: true }, + preventOverflow: { enabled: true }, + }} + autoFocus={false} + content={ +
+
+
Search Results
+
+ + {isFilterMenuOpen && ( +
+
Filter by type:
+
+ {DISCOURSE_TYPES.map((type) => ( +
handleTypeCheckChange(type.type, e)} + > + {}} + className="m-0" + /> +
+ ))} +
+
+ )} + +
+ {filteredTypes.map((type, typeIndex) => ( +
+
+ {type.title} +
+ + {type.items.map((item) => { + currentGlobalIndex++; + const isActive = currentGlobalIndex === activeIndex; + return ( + setActiveIndex(currentGlobalIndex)} + onClick={() => onSelect(item)} + /> + ); + })} + +
+ ))} + + {allItems.length === 0 && ( +
+ No matches found +
+ )} +
+
+ } + /> + ); +}; + +export const renderDiscourseNodeSearchMenu = (props: Props) => { + const parent = document.createElement("span"); + const coords = getCoordsFromTextarea(props.textarea); + parent.style.position = "absolute"; + parent.style.left = `${coords.left}px`; + parent.style.top = `${coords.top}px`; + props.textarea.parentElement?.insertBefore(parent, props.textarea); + + ReactDOM.render( + { + props.onClose(); + ReactDOM.unmountComponentAtNode(parent); + parent.remove(); + }} + />, + parent, + ); +}; + +export default NodeSearchMenu; diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 34efa56b5..d25c39bf6 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -98,11 +98,16 @@ export default runExtension(async (onloadArgs) => { const settingsStyle = addStyle(settingsStyles); const { observers, listeners } = await initObservers({ onloadArgs }); - const [pageActionListener, hashChangeListener, nodeMenuTriggerListener] = - listeners; + const { + pageActionListener, + hashChangeListener, + nodeMenuTriggerListener, + discourseNodeSearchTriggerListener, + } = listeners; document.addEventListener("roamjs:query-builder:action", pageActionListener); window.addEventListener("hashchange", hashChangeListener); document.addEventListener("keydown", nodeMenuTriggerListener); + document.addEventListener("keydown", discourseNodeSearchTriggerListener); const { extensionAPI } = onloadArgs; window.roamjs.extension.queryBuilder = { @@ -133,6 +138,10 @@ export default runExtension(async (onloadArgs) => { ); window.removeEventListener("hashchange", hashChangeListener); document.removeEventListener("keydown", nodeMenuTriggerListener); + document.removeEventListener( + "keydown", + discourseNodeSearchTriggerListener, + ); window.roamAlphaAPI.ui.graphView.wholeGraph.removeCallback({ label: "discourse-node-styling", }); diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index df2a2f36d..e5bb9b728 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -34,6 +34,7 @@ import { } from "~/components/DiscourseNodeMenu"; import { IKeyCombo } from "@blueprintjs/core"; import { configPageTabs } from "~/utils/configPageTabs"; +import { renderDiscourseNodeSearchMenu } from "~/components/DiscourseNodeSearchMenu"; export const initObservers = async ({ onloadArgs, @@ -41,7 +42,12 @@ export const initObservers = async ({ onloadArgs: OnloadArgs; }): Promise<{ observers: MutationObserver[]; - listeners: EventListener[]; + listeners: { + pageActionListener: EventListener; + hashChangeListener: EventListener; + nodeMenuTriggerListener: EventListener; + discourseNodeSearchTriggerListener: EventListener; + }; }> => { const pageTitleObserver = createHTMLObserver({ tag: "H1", @@ -180,6 +186,37 @@ export const initObservers = async ({ } }; + const discourseNodeSearchTriggerListener = (e: Event) => { + const evt = e as KeyboardEvent; + const target = evt.target as HTMLElement; + if (document.querySelector(".discourse-node-search-menu")) return; + + if (evt.key === "@" || (evt.key === "2" && evt.shiftKey)) { + if ( + target.tagName === "TEXTAREA" && + target.classList.contains("rm-block-input") + ) { + const textarea = target as HTMLTextAreaElement; + const location = window.roamAlphaAPI.ui.getFocusedBlock(); + if (!location) return; + + const cursorPos = textarea.selectionStart; + const isBeginningOrAfterSpace = + cursorPos === 0 || + textarea.value.charAt(cursorPos - 1) === " " || + textarea.value.charAt(cursorPos - 1) === "\n"; + + if (isBeginningOrAfterSpace) { + renderDiscourseNodeSearchMenu({ + onClose: () => {}, + textarea: textarea, + triggerPosition: cursorPos, + }); + } + } + } + }; + return { observers: [ pageTitleObserver, @@ -188,10 +225,11 @@ export const initObservers = async ({ linkedReferencesObserver, graphOverviewExportObserver, ].filter((o): o is MutationObserver => !!o), - listeners: [ + listeners: { pageActionListener, hashChangeListener, nodeMenuTriggerListener, - ], + discourseNodeSearchTriggerListener, + }, }; }; From 5b410c20d51cf5f44e89698ac90d4e4ab5cc3f12 Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Thu, 15 May 2025 00:00:57 -0400 Subject: [PATCH 2/5] [ENG-261] Query function for DG summoning menu (#150) * cur progress * current progress * rm logs * address PR comments * address PR comments * query works * address PR comments * address PR comments * sm change --- .../components/DiscourseNodeSearchMenu.tsx | 364 +++++++++++------- apps/roam/src/utils/formatUtils.ts | 4 + .../utils/initializeObserversAndListeners.ts | 1 - 3 files changed, 239 insertions(+), 130 deletions(-) diff --git a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx index 83206c262..b5acc8ab0 100644 --- a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx @@ -12,7 +12,6 @@ import { Position, Checkbox, Button, - Icon, } from "@blueprintjs/core"; import ReactDOM from "react-dom"; import getUids from "roamjs-components/dom/getUids"; @@ -20,6 +19,10 @@ import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import updateBlock from "roamjs-components/writes/updateBlock"; import posthog from "posthog-js"; import { getCoordsFromTextarea } from "roamjs-components/components/CursorMenu"; +import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; +import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression"; +import { escapeCljString } from "~/utils/formatUtils"; +import { Result } from "~/utils/types"; type Props = { textarea: HTMLTextAreaElement; @@ -27,41 +30,6 @@ type Props = { onClose: () => void; }; -type DiscourseType = { - type: string; - title: string; - items: { id: string; text: string }[]; -}; - -// Hardcoded discourse types for testing -// TODO: replace with actual discourse types -const DISCOURSE_TYPES: DiscourseType[] = [ - { - type: "claims", - title: "Claims", - items: [ - { id: "clm1", text: "[[CLM]] - Claim 1" }, - { id: "clm2", text: "[[CLM]] - Claim 1" }, - ], - }, - { - type: "evidence", - title: "Evidence", - items: [ - { id: "evd1", text: "[[EVD]] - Evidence 1" }, - { id: "evd2", text: "[[EVD]] - Evidence 2" }, - ], - }, - { - type: "results", - title: "Results", - items: [ - { id: "res1", text: "[[RES]] - Result 1" }, - { id: "res2", text: "[[RES]] - Result 1" }, - ], - }, -]; - const waitForBlock = ( uid: string, text: string, @@ -86,10 +54,102 @@ const NodeSearchMenu = ({ }: { onClose: () => void } & Props) => { const [activeIndex, setActiveIndex] = useState(0); const [searchTerm, setSearchTerm] = useState(""); - const [checkedTypes, setCheckedTypes] = useState>( - DISCOURSE_TYPES.reduce((acc, type) => ({ ...acc, [type.type]: true }), {}), - ); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); + const [discourseTypes, setDiscourseTypes] = useState([]); + const [checkedTypes, setCheckedTypes] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [searchResults, setSearchResults] = useState>( + {}, + ); + const scrollContainerRef = useRef(null); + const searchTimeoutRef = useRef(null); + + const debouncedSearchTerm = useCallback((term: string) => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = setTimeout(() => { + setSearchTerm(term); + }, 300); + }, []); + + const searchNodesForType = ( + node: DiscourseNode, + searchTerm: string, + ): Result[] => { + if (!node.format) return []; + + try { + const regex = getDiscourseNodeFormatExpression(node.format); + + const regexPattern = regex.source + .replace(/^\^/, "") + .replace(/\$$/, "") + .replace(/\\/g, "\\\\"); + + const query = `[ + :find + (pull ?node [:block/string :node/title :block/uid]) + :where + [(re-pattern "${regexPattern}") ?title-regex] + [?node :node/title ?node-title] + ${searchTerm ? `[(clojure.string/includes? ?node-title "${escapeCljString(searchTerm)}")]` : ""} + [(re-find ?title-regex ?node-title)] + ]`; + const results = window.roamAlphaAPI.q(query); + + return results.map(([result]: any) => ({ + id: result.uid, + text: result.title || result.string, + uid: result.uid, + })); + } catch (error) { + console.error(`Error querying for node type ${node.type}:`, error); + return []; + } + }; + + useEffect(() => { + const fetchNodeTypes = async () => { + setIsLoading(true); + + const allNodeTypes = getDiscourseNodes().filter( + (n) => n.backedBy === "user", + ); + + setDiscourseTypes(allNodeTypes); + + const initialCheckedTypes: Record = {}; + allNodeTypes.forEach((t) => { + initialCheckedTypes[t.type] = true; + }); + setCheckedTypes(initialCheckedTypes); + + const initialSearchResults = allNodeTypes.reduce( + (acc, type) => ({ ...acc, [type.type]: [] }), + {}, + ); + setSearchResults(initialSearchResults); + + setIsLoading(false); + }; + + fetchNodeTypes(); + }, []); + + useEffect(() => { + if (isLoading) return; + + const newResults: Record = {}; + + discourseTypes.forEach((type) => { + newResults[type.type] = searchNodesForType(type, searchTerm); + }); + + setSearchResults(newResults); + }, [searchTerm, isLoading, discourseTypes]); + const menuRef = useRef(null); const { ["block-uid"]: blockUid, ["window-id"]: windowId } = useMemo( () => @@ -101,40 +161,30 @@ const NodeSearchMenu = ({ ); const filteredTypes = useMemo(() => { - const typesToShow = DISCOURSE_TYPES.filter( - (type) => checkedTypes[type.type], - ); - - if (!searchTerm.trim()) return typesToShow; - - return typesToShow - .map((type) => ({ - ...type, - items: type.items.filter((item) => - item.text.toLowerCase().includes(searchTerm.toLowerCase()), - ), - })) - .filter((type) => type.items.length > 0); - }, [searchTerm, checkedTypes]); + return discourseTypes + .filter((type) => checkedTypes[type.type]) + .filter((type) => searchResults[type.type]?.length > 0); + }, [discourseTypes, checkedTypes, searchResults]); const allItems = useMemo(() => { const items: { typeIndex: number; itemIndex: number; - item: { id: string; text: string }; + item: Result; }[] = []; filteredTypes.forEach((type, typeIndex) => { - type.items.forEach((item, itemIndex) => { + const typeResults = searchResults[type.type] || []; + typeResults.forEach((item, itemIndex) => { items.push({ typeIndex, itemIndex, item }); }); }); return items; - }, [filteredTypes]); + }, [filteredTypes, searchResults]); const onSelect = useCallback( - (item: { id: string; text: string }) => { + (item: Result) => { waitForBlock(blockUid, textarea.value).then(() => { onClose(); @@ -175,7 +225,6 @@ const NodeSearchMenu = ({ }, 50); } }); - posthog.capture("Discourse Node: Selected from Search Menu", { id: item.id, text: item.text, @@ -183,7 +232,7 @@ const NodeSearchMenu = ({ }, 10); }); }, - [blockUid, onClose, searchTerm, textarea], + [blockUid, onClose, textarea, triggerPosition, windowId], ); const handleTextAreaInput = useCallback(() => { @@ -194,20 +243,20 @@ const NodeSearchMenu = ({ ); const match = atTriggerRegex.exec(textBeforeCursor); if (match) { - setSearchTerm(match[1]); + debouncedSearchTerm(match[1]); } else { onClose(); return; } - }, [textarea, onClose, setSearchTerm, triggerPosition]); + }, [textarea, onClose, debouncedSearchTerm, triggerPosition]); const keydownListener = useCallback( (e: KeyboardEvent) => { - if (e.key === "ArrowDown") { + if (e.key === "ArrowDown" && allItems.length) { setActiveIndex((prev) => (prev + 1) % allItems.length); e.preventDefault(); e.stopPropagation(); - } else if (e.key === "ArrowUp") { + } else if (e.key === "ArrowUp" && allItems.length) { setActiveIndex( (prev) => (prev - 1 + allItems.length) % allItems.length, ); @@ -242,14 +291,22 @@ const NodeSearchMenu = ({ }, [handleTextAreaInput, textarea]); useEffect(() => { - const listeningEl = !!textarea.closest(".rm-reference-item") + const listeningEl = textarea.closest(".rm-reference-item") ? textarea.parentElement : textarea; - listeningEl?.addEventListener("keydown", keydownListener); + + if (listeningEl) { + listeningEl.addEventListener("keydown", keydownListener); + listeningEl.addEventListener("input", handleTextAreaInput); + } + return () => { - listeningEl?.removeEventListener("keydown", keydownListener); + if (listeningEl) { + listeningEl.removeEventListener("keydown", keydownListener); + listeningEl.removeEventListener("input", handleTextAreaInput); + } }; - }, [keydownListener]); + }, [textarea, keydownListener, handleTextAreaInput]); useEffect(() => { setTimeout(() => { @@ -257,6 +314,38 @@ const NodeSearchMenu = ({ }, 50); }, [handleTextAreaInput]); + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (scrollContainerRef.current) { + const activeItem = scrollContainerRef.current.querySelector( + '[data-active="true"]', + ) as HTMLElement; + + if (activeItem) { + const container = scrollContainerRef.current; + const containerRect = container.getBoundingClientRect(); + const itemRect = activeItem.getBoundingClientRect(); + + if ( + itemRect.bottom > containerRect.bottom || + itemRect.top < containerRect.top + ) { + activeItem.scrollIntoView({ + block: "nearest", + behavior: "auto", + }); + } + } + } + }, [activeIndex]); + let currentGlobalIndex = -1; const handleTypeCheckChange = useCallback( @@ -322,71 +411,88 @@ const NodeSearchMenu = ({ onMouseDown={remainFocusOnTextarea} onClick={remainFocusOnTextarea} > -
-
Search Results
-
- - {isFilterMenuOpen && ( -
-
Filter by type:
-
- {DISCOURSE_TYPES.map((type) => ( -
handleTypeCheckChange(type.type, e)} - > - {}} - className="m-0" - /> -
- ))} -
-
- )} - -
- {filteredTypes.map((type, typeIndex) => ( -
-
- {type.title} + {isLoading ? ( +
Loading...
+ ) : ( + <> +
+
+
Search Results
+
- - {type.items.map((item) => { - currentGlobalIndex++; - const isActive = currentGlobalIndex === activeIndex; - return ( - setActiveIndex(currentGlobalIndex)} - onClick={() => onSelect(item)} - /> - ); - })} - + + {isFilterMenuOpen && ( +
+
+ Filter by type: +
+
+ {discourseTypes.map((type) => ( +
handleTypeCheckChange(type.type, e)} + > + {}} + className="m-0" + /> +
+ ))} +
+
+ )}
- ))} +
+ {filteredTypes.map((type) => ( +
+
+ {type.text} +
+ + {searchResults[type.type]?.map((item) => { + currentGlobalIndex++; + const isActive = currentGlobalIndex === activeIndex; + return ( + + setActiveIndex(currentGlobalIndex) + } + onClick={() => onSelect(item)} + /> + ); + })} + +
+ ))} - {allItems.length === 0 && ( -
- No matches found + {allItems.length === 0 && ( +
+ No matches found +
+ )}
- )} -
+ + )}
} /> diff --git a/apps/roam/src/utils/formatUtils.ts b/apps/roam/src/utils/formatUtils.ts index 814d67027..580ba501a 100644 --- a/apps/roam/src/utils/formatUtils.ts +++ b/apps/roam/src/utils/formatUtils.ts @@ -195,3 +195,7 @@ export const findReferencedNodeInText = ({ text: pageTitle, } as Result; }; + +export const escapeCljString = (str: string) => { + return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').toLowerCase(); +}; diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index e5bb9b728..9c2479116 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -205,7 +205,6 @@ export const initObservers = async ({ cursorPos === 0 || textarea.value.charAt(cursorPos - 1) === " " || textarea.value.charAt(cursorPos - 1) === "\n"; - if (isBeginningOrAfterSpace) { renderDiscourseNodeSearchMenu({ onClose: () => {}, From 59926c84e2cfe1e33b33e6fa740950df2a6ffe74 Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Mon, 19 May 2025 22:22:21 -0400 Subject: [PATCH 3/5] [ENG-299] Trigger setting for DG summoning menu (#162) * setting menu done * trigger works correctly * clean up * address PR comment * optimize getFocusedBlock() * address nit comments --- .../components/DiscourseNodeSearchMenu.tsx | 48 ++++++++++++++-- .../settings/HomePersonalSettings.tsx | 10 ++++ apps/roam/src/index.ts | 7 +-- .../utils/initializeObserversAndListeners.ts | 56 +++++++++++++------ 4 files changed, 96 insertions(+), 25 deletions(-) diff --git a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx index b5acc8ab0..89905dabc 100644 --- a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx @@ -12,6 +12,7 @@ import { Position, Checkbox, Button, + InputGroup, } from "@blueprintjs/core"; import ReactDOM from "react-dom"; import getUids from "roamjs-components/dom/getUids"; @@ -19,6 +20,7 @@ import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import updateBlock from "roamjs-components/writes/updateBlock"; import posthog from "posthog-js"; import { getCoordsFromTextarea } from "roamjs-components/components/CursorMenu"; +import { OnloadArgs } from "roamjs-components/types"; import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression"; import { escapeCljString } from "~/utils/formatUtils"; @@ -28,6 +30,7 @@ type Props = { textarea: HTMLTextAreaElement; triggerPosition: number; onClose: () => void; + triggerText: string; }; const waitForBlock = ( @@ -51,6 +54,7 @@ const NodeSearchMenu = ({ onClose, textarea, triggerPosition, + triggerText, }: { onClose: () => void } & Props) => { const [activeIndex, setActiveIndex] = useState(0); const [searchTerm, setSearchTerm] = useState(""); @@ -236,19 +240,21 @@ const NodeSearchMenu = ({ ); const handleTextAreaInput = useCallback(() => { - const atTriggerRegex = /@(.*)$/; + const triggerRegex = new RegExp( + `${triggerText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(.*)$`, + ); const textBeforeCursor = textarea.value.substring( triggerPosition, textarea.selectionStart, ); - const match = atTriggerRegex.exec(textBeforeCursor); + const match = triggerRegex.exec(textBeforeCursor); if (match) { debouncedSearchTerm(match[1]); } else { onClose(); return; } - }, [textarea, onClose, debouncedSearchTerm, triggerPosition]); + }, [textarea, onClose, debouncedSearchTerm, triggerPosition, triggerText]); const keydownListener = useCallback( (e: KeyboardEvent) => { @@ -274,7 +280,7 @@ const NodeSearchMenu = ({ e.stopPropagation(); } }, - [allItems, setActiveIndex, onSelect, onClose], + [allItems, activeIndex, setActiveIndex, onSelect, onClose], ); useEffect(() => { @@ -520,4 +526,38 @@ export const renderDiscourseNodeSearchMenu = (props: Props) => { ); }; +export const NodeSearchMenuTriggerSetting = ({ + onloadArgs, +}: { + onloadArgs: OnloadArgs; +}) => { + const extensionAPI = onloadArgs.extensionAPI; + const [nodeSearchTrigger, setNodeSearchTrigger] = useState( + extensionAPI.settings.get("node-search-trigger") as string, + ); + + const handleNodeSearchTriggerChange = ( + e: React.ChangeEvent, + ) => { + const value = e.target.value.trim(); + const trigger = value + .replace(/"/g, "") + .replace(/\\/g, "\\\\") + .replace(/\+/g, "\\+") + .trim(); + + setNodeSearchTrigger(trigger); + extensionAPI.settings.set("node-search-trigger", trigger); + }; + return ( + + ); +}; + + export default NodeSearchMenu; diff --git a/apps/roam/src/components/settings/HomePersonalSettings.tsx b/apps/roam/src/components/settings/HomePersonalSettings.tsx index de30859cb..f277bb263 100644 --- a/apps/roam/src/components/settings/HomePersonalSettings.tsx +++ b/apps/roam/src/components/settings/HomePersonalSettings.tsx @@ -12,6 +12,7 @@ import { hideFeedbackButton, showFeedbackButton, } from "~/components/BirdEatsBugs"; +import { NodeSearchMenuTriggerSetting } from "../DiscourseNodeSearchMenu"; const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { const extensionAPI = onloadArgs.extensionAPI; @@ -28,6 +29,15 @@ const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { /> + { document.addEventListener("roamjs:query-builder:action", pageActionListener); window.addEventListener("hashchange", hashChangeListener); document.addEventListener("keydown", nodeMenuTriggerListener); - document.addEventListener("keydown", discourseNodeSearchTriggerListener); + document.addEventListener("input", discourseNodeSearchTriggerListener); const { extensionAPI } = onloadArgs; window.roamjs.extension.queryBuilder = { @@ -138,10 +138,7 @@ export default runExtension(async (onloadArgs) => { ); window.removeEventListener("hashchange", hashChangeListener); document.removeEventListener("keydown", nodeMenuTriggerListener); - document.removeEventListener( - "keydown", - discourseNodeSearchTriggerListener, - ); + document.removeEventListener("input", discourseNodeSearchTriggerListener); window.roamAlphaAPI.ui.graphView.wholeGraph.removeCallback({ label: "discourse-node-styling", }); diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index 9c2479116..ec0423936 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -186,30 +186,54 @@ export const initObservers = async ({ } }; + const customTrigger = onloadArgs.extensionAPI.settings.get( + "node-search-trigger", + ) as string; + const discourseNodeSearchTriggerListener = (e: Event) => { const evt = e as KeyboardEvent; const target = evt.target as HTMLElement; + if (document.querySelector(".discourse-node-search-menu")) return; - if (evt.key === "@" || (evt.key === "2" && evt.shiftKey)) { - if ( - target.tagName === "TEXTAREA" && - target.classList.contains("rm-block-input") - ) { - const textarea = target as HTMLTextAreaElement; - const location = window.roamAlphaAPI.ui.getFocusedBlock(); - if (!location) return; - - const cursorPos = textarea.selectionStart; - const isBeginningOrAfterSpace = - cursorPos === 0 || - textarea.value.charAt(cursorPos - 1) === " " || - textarea.value.charAt(cursorPos - 1) === "\n"; - if (isBeginningOrAfterSpace) { + if ( + target.tagName === "TEXTAREA" && + target.classList.contains("rm-block-input") + ) { + const textarea = target as HTMLTextAreaElement; + + if (!customTrigger) return; + + const cursorPos = textarea.selectionStart; + const textBeforeCursor = textarea.value.substring(0, cursorPos); + + const lastTriggerPos = textBeforeCursor.lastIndexOf(customTrigger); + + if (lastTriggerPos >= 0) { + const charBeforeTrigger = + lastTriggerPos > 0 + ? textBeforeCursor.charAt(lastTriggerPos - 1) + : null; + + const isValidTriggerPosition = + lastTriggerPos === 0 || + charBeforeTrigger === " " || + charBeforeTrigger === "\n"; + + const isCursorAfterTrigger = + cursorPos === lastTriggerPos + customTrigger.length; + + if (isValidTriggerPosition && isCursorAfterTrigger) { + // Double-check we have an active block context via Roam's API + // This guards against edge cases where the DOM shows an input but Roam's internal state disagrees + const isEditingBlock = !!window.roamAlphaAPI.ui.getFocusedBlock(); + if (!isEditingBlock) return; + renderDiscourseNodeSearchMenu({ onClose: () => {}, textarea: textarea, - triggerPosition: cursorPos, + triggerPosition: lastTriggerPos, + triggerText: customTrigger, }); } } From de46825989785731d3b23b1d54aa67860b83bb5a Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Tue, 20 May 2025 19:19:03 -0400 Subject: [PATCH 4/5] fix bug (#166) --- apps/roam/src/components/DiscourseNodeSearchMenu.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx index 89905dabc..1d6e23dbb 100644 --- a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx @@ -480,9 +480,6 @@ const NodeSearchMenu = ({ text={item.text} data-active={isActive} active={isActive} - onMouseEnter={() => - setActiveIndex(currentGlobalIndex) - } onClick={() => onSelect(item)} /> ); From 5ecac8cb8cbb9c390b2183a7097f9d49b8203b81 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 22 May 2025 14:12:34 -0400 Subject: [PATCH 5/5] address PR comments --- apps/roam/src/components/DiscourseNodeSearchMenu.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx index 1d6e23dbb..714eeb8de 100644 --- a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx @@ -130,9 +130,8 @@ const NodeSearchMenu = ({ }); setCheckedTypes(initialCheckedTypes); - const initialSearchResults = allNodeTypes.reduce( - (acc, type) => ({ ...acc, [type.type]: [] }), - {}, + const initialSearchResults = Object.fromEntries( + allNodeTypes.map((type) => [type.type, []]), ); setSearchResults(initialSearchResults); @@ -289,13 +288,6 @@ const NodeSearchMenu = ({ } }, [allItems, activeIndex]); - useEffect(() => { - textarea.addEventListener("input", handleTextAreaInput); - return () => { - textarea.removeEventListener("input", handleTextAreaInput); - }; - }, [handleTextAreaInput, textarea]); - useEffect(() => { const listeningEl = textarea.closest(".rm-reference-item") ? textarea.parentElement