From 958cdee7a6d9d04c34294902c37b5921a7dc2c0e Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 21 Jul 2025 13:29:35 +0530 Subject: [PATCH 01/21] real time validation --- .../src/components/settings/NodeConfig.tsx | 112 ++++++++++++++++-- 1 file changed, 102 insertions(+), 10 deletions(-) diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 8de66eae6..37910fdfc 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { DiscourseNode } from "~/utils/getDiscourseNodes"; import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel"; import SelectPanel from "roamjs-components/components/ConfigPanels/SelectPanel"; @@ -12,6 +12,8 @@ import DiscourseNodeAttributes from "./DiscourseNodeAttributes"; import DiscourseNodeCanvasSettings from "./DiscourseNodeCanvasSettings"; import DiscourseNodeIndex from "./DiscourseNodeIndex"; import { OnloadArgs } from "roamjs-components/types"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; +import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; const NodeConfig = ({ node, @@ -28,6 +30,7 @@ const NodeConfig = ({ const formatUid = getUid("Format"); const descriptionUid = getUid("Description"); const shortcutUid = getUid("Shortcut"); + const tagUid = getUid("Tag"); const templateUid = getUid("Template"); const overlayUid = getUid("Overlay"); const canvasUid = getUid("Canvas"); @@ -41,6 +44,73 @@ const NodeConfig = ({ const [selectedTabId, setSelectedTabId] = useState("general"); + // State for tracking current values and validation + const [currentTagValue, setCurrentTagValue] = useState(node.tag || ""); + const [currentFormatValue, setCurrentFormatValue] = useState( + node.format || "", + ); + + // Function to extract clean tag text (remove # if present) + const getCleanTagText = (tag: string): string => { + return tag.replace(/^#+/, "").trim().toUpperCase(); + }; + + // Function to check if tag text appears in format + const validateTagFormatConflict = useMemo(() => { + const cleanTag = getCleanTagText(currentTagValue); + if (!cleanTag) return { isValid: true, message: "" }; + + // Remove placeholders like {content} before validation + const formatWithoutPlaceholders = currentFormatValue.replace( + /{[^}]+}/g, + "", + ); + const formatUpper = formatWithoutPlaceholders.toUpperCase(); + + // Split format by non-alphanumeric characters to check for the exact tag + const formatParts = formatUpper.split(/[^A-Z0-9]/); + const hasConflict = formatParts.includes(cleanTag); + + let message = ""; + if (hasConflict) { + const formatForMessage = formatWithoutPlaceholders + .trim() + .replace(/(\s*-)*$/, ""); + if (selectedTabId === "format") { + message = `Format "${formatForMessage}" conflicts with tag: "${currentTagValue}". Please use some other format.`; + } else { + // Default message for 'general' tab and any other case + message = `Tag "${currentTagValue}" conflicts with format "${formatForMessage}". Please use some other tag.`; + } + } + + return { + isValid: !hasConflict, + message, + }; + }, [currentTagValue, currentFormatValue, selectedTabId]); + + // Effect to update current values when they change in the blocks + useEffect(() => { + const updateValues = () => { + try { + const tagValue = getBasicTreeByParentUid(tagUid)[0]?.text || ""; + const formatValue = getBasicTreeByParentUid(formatUid)[0]?.text || ""; + setCurrentTagValue(tagValue); + setCurrentFormatValue(formatValue); + } catch (error) { + // Handle case where blocks might not exist yet + console.warn("Error updating tag/format values:", error); + } + }; + + // Update values initially and set up periodic updates + updateValues(); + const interval = setInterval(updateValues, 500); + + return () => clearInterval(interval); + }, [tagUid, formatUid]); + return ( <> +
+
+ + {!validateTagFormatConflict.isValid && ( +
+ {validateTagFormatConflict.message} +
+ )} +
} /> @@ -90,14 +175,21 @@ const NodeConfig = ({ title="Format" panel={
- +
+ + {!validateTagFormatConflict.isValid && ( +
+ {validateTagFormatConflict.message} +
+ )} +
+ + ); +}; + +export const renderCreateNodeDialog = ( + props: Omit, +) => + renderOverlay({ + Overlay: CreateNodeDialog, + props: { ...props, isOpen: true }, + }); diff --git a/apps/roam/src/components/DiscourseNodeMenu.tsx b/apps/roam/src/components/DiscourseNodeMenu.tsx index 89d6bfc32..e209de4ea 100644 --- a/apps/roam/src/components/DiscourseNodeMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeMenu.tsx @@ -242,7 +242,7 @@ const NodeMenu = ({ key={item.text} data-node={item.type} data-tag={item.tag} - text={showNodeTypes ? item.text : `#${item.tag || 'untagged'}`} + text={showNodeTypes ? item.text : `#${item.tag || "untagged"}`} active={i === activeIndex} onMouseEnter={() => setActiveIndex(i)} onClick={() => onSelect(i)} diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index e87b3965b..950ae12e1 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -102,12 +102,14 @@ export default runExtension(async (onloadArgs) => { nodeMenuTriggerListener, discourseNodeSearchTriggerListener, nodeCreationPopoverListener, + nodeTagHoverListener, } = listeners; document.addEventListener("roamjs:query-builder:action", pageActionListener); window.addEventListener("hashchange", hashChangeListener); document.addEventListener("keydown", nodeMenuTriggerListener); document.addEventListener("input", discourseNodeSearchTriggerListener); document.addEventListener("selectionchange", nodeCreationPopoverListener); + document.addEventListener("mouseover", nodeTagHoverListener); await initializeDiscourseNodes(); refreshConfigTree(); @@ -146,6 +148,7 @@ export default runExtension(async (onloadArgs) => { "selectionchange", nodeCreationPopoverListener, ); + document.removeEventListener("mouseover", nodeTagHoverListener); 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 bbc45414f..d38798377 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -40,6 +40,10 @@ import { removeTextSelectionPopup, findBlockElementFromSelection, } from "~/utils/renderTextSelectionPopup"; +import { renderNodeTagPopup, removeNodeTagPopup } from "./renderNodeTagPopup"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; +import { renderCreateNodeDialog } from "~/components/CreateNodeDialog"; +import getUids from "roamjs-components/dom/getUids"; export const initObservers = async ({ onloadArgs, @@ -53,6 +57,7 @@ export const initObservers = async ({ nodeMenuTriggerListener: EventListener; discourseNodeSearchTriggerListener: EventListener; nodeCreationPopoverListener: EventListener; + nodeTagHoverListener: EventListener; }; }> => { const pageTitleObserver = createHTMLObserver({ @@ -289,6 +294,54 @@ export const initObservers = async ({ } }; + const nodeTagHoverListener = (e: Event) => { + const target = e.target as HTMLElement | null; + if (!target || !target.classList?.contains("rm-page-ref")) return; + + const textContent = target.textContent?.trim() || ""; + const tagAttr = target.getAttribute("data-link-title") || textContent; + const tag = tagAttr.replace(/^#/, "").toLowerCase(); + const discourseTagSet = new Set( + getDiscourseNodes() + .map((n) => n.tag?.toLowerCase()) + .filter(Boolean) as string[], + ); + + if (!discourseTagSet.has(tag)) return; + + const matchedNode = getDiscourseNodes().find( + (n) => n.tag?.toLowerCase() === tag, + ); + + if (!matchedNode) return; + + const blockInputElement = (e.target as HTMLElement).closest( + ".rm-block__input", + ); + const blockUid = blockInputElement + ? getUids(blockInputElement as HTMLDivElement).blockUid + : undefined; + + const rawBlockText = blockUid ? getTextByBlockUid(blockUid) : ""; + const cleanedBlockText = rawBlockText.replace(/#\w+/g, "").trim(); + + renderNodeTagPopup({ + tagElement: target, + label: `+ Create ${matchedNode.text}`, + onClick: () => { + renderCreateNodeDialog({ + onClose: removeNodeTagPopup, + nodeTypes: getDiscourseNodes(), + defaultNodeType: matchedNode, + extensionAPI: onloadArgs.extensionAPI, + blockUid, + originalTagText: textContent, + initialTitle: cleanedBlockText, + }); + }, + }); + }; + return { observers: [ pageTitleObserver, @@ -303,6 +356,7 @@ export const initObservers = async ({ nodeMenuTriggerListener, discourseNodeSearchTriggerListener, nodeCreationPopoverListener, + nodeTagHoverListener, }, }; }; diff --git a/apps/roam/src/utils/renderNodeTagPopup.tsx b/apps/roam/src/utils/renderNodeTagPopup.tsx new file mode 100644 index 000000000..139188139 --- /dev/null +++ b/apps/roam/src/utils/renderNodeTagPopup.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Button } from "@blueprintjs/core"; + +let currentPopup: HTMLDivElement | null = null; + +export const removeNodeTagPopup = () => { + if (currentPopup) { + ReactDOM.unmountComponentAtNode(currentPopup); + currentPopup.remove(); + currentPopup = null; + } +}; + +export const renderNodeTagPopup = ({ + tagElement, + onClick, + label = "Create node", +}: { + tagElement: HTMLElement; + onClick: () => void; + label?: string; +}) => { + removeNodeTagPopup(); + + const rect = tagElement.getBoundingClientRect(); + + currentPopup = document.createElement("div"); + currentPopup.id = "discourse-node-tag-popup"; + currentPopup.style.position = "absolute"; + currentPopup.style.left = `${rect.left + window.scrollX}px`; + currentPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; + currentPopup.className = "z-[9999] max-w-none font-inherit bg-white"; + + document.body.appendChild(currentPopup); + + // Remove when pointer leaves the popup + currentPopup.addEventListener("mouseleave", removeNodeTagPopup, { + once: true, + }); + + ReactDOM.render( +