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, }); } }