diff --git a/apps/roam/src/styles/styles.css b/apps/roam/src/styles/styles.css index caa4b3100..8fe3595fe 100644 --- a/apps/roam/src/styles/styles.css +++ b/apps/roam/src/styles/styles.css @@ -135,3 +135,15 @@ width: 100px; justify-content: space-between; } + +.rm-select-menu__item--highlighted { + background-color: #e6f0f5; +} + +.dg-popover .bp3-popover { + z-index: 10000; +} + +.text-selection-popup { + z-index: 1000; +} diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index bce287b0a..acc1a867f 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -25,7 +25,7 @@ import { previewPageRefHandler, overlayPageRefHandler, } from "~/utils/pageRefObserverHandlers"; -import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; import { OnloadArgs } from "roamjs-components/types"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { render as renderGraphOverviewExport } from "~/components/ExportDiscourseContext"; @@ -45,6 +45,16 @@ import { } from "~/utils/renderTextSelectionPopup"; import { renderNodeTagPopupButton } from "./renderNodeTagPopup"; +let discourseNodes: DiscourseNode[] = []; +let discourseTagSet: Set = new Set(); + +const refreshDiscourseNodeCache = () => { + discourseNodes = getDiscourseNodes(); + discourseTagSet = new Set( + discourseNodes.flatMap((n) => (n.tag ? [n.tag.toLowerCase()] : [])), + ); +}; + const debounce = (fn: () => void, delay = 250) => { let timeout: number; return () => { @@ -67,6 +77,7 @@ export const initObservers = async ({ nodeCreationPopoverListener: EventListener; }; }> => { + refreshDiscourseNodeCache(); const pageTitleObserver = createHTMLObserver({ tag: "H1", className: "rm-title-display", @@ -102,7 +113,208 @@ export const initObservers = async ({ className: "rm-page-ref--tag", tag: "SPAN", callback: (s: HTMLSpanElement) => { - renderNodeTagPopupButton(s, onloadArgs.extensionAPI); + const tag = s.getAttribute("data-tag"); + if (tag) { + if (discourseTagSet.has(tag.toLowerCase())) { + renderNodeTagPopupButton(s, discourseNodes, onloadArgs.extensionAPI); + } + } + }, + }); + + const tableTagObserver = createHTMLObserver({ + tag: "TD", + className: "relative", + callback: (el: HTMLElement) => { + if (!(el instanceof HTMLTableCellElement)) return; + + const td = el; + if (!td.hasAttribute("data-cell-content")) return; + + const content = td.dataset.cellContent || ""; + if (!content.includes("#")) return; + + const discourseNodes = getDiscourseNodes(); + const discourseTagSet = new Set( + discourseNodes.map((n) => n.tag?.toLowerCase()).filter(Boolean), + ); + + const existingTags = td.querySelectorAll(".rm-page-ref--tag"); + existingTags.forEach((tag) => { + const tagName = tag.getAttribute("data-tag"); + if ( + tagName && + discourseTagSet.has(tagName.toLowerCase()) && + tag instanceof HTMLSpanElement + ) { + tag.removeAttribute("data-attribute-button-rendered"); + renderNodeTagPopupButton(tag, onloadArgs.extensionAPI); + } + }); + + const innerContainers = [ + td.querySelector("a.rm-page-ref > span"), + td.querySelector("div.rm-block__input > span"), + td.querySelector("div.rm-block__input"), + td.querySelector("div.roamjs-query-embed span"), + ].filter(Boolean); + + innerContainers.forEach((innerSpan) => { + if (!innerSpan) return; + + const formattedTagsInside = + innerSpan.querySelectorAll(".rm-page-ref--tag"); + if (formattedTagsInside.length > 0) return; + + const textContent = innerSpan.textContent || ""; + const unformattedDiscourseTagsFound = []; + + discourseNodes.forEach((node) => { + const tag = node.tag; + if (!tag) return; + + const pattern = new RegExp(`#${tag}(?![\\w-])`, "i"); + if (pattern.test(textContent)) { + const alreadyFormatted = innerSpan.querySelector( + `.rm-page-ref--tag[data-tag="${tag}"]`, + ); + if (!alreadyFormatted) { + unformattedDiscourseTagsFound.push(tag); + } + } + }); + + if (unformattedDiscourseTagsFound.length === 0) return; + + const originalHtml = innerSpan.innerHTML; + const newHtml = originalHtml.replace(/#([\w-]+)/g, (match, tagName) => { + if ( + innerSpan.querySelector(`.rm-page-ref--tag[data-tag="${tagName}"]`) + ) { + return match; + } + + if (discourseTagSet.has(tagName.toLowerCase())) { + return `${match}`; + } + + return match; + }); + + if (originalHtml !== newHtml) { + innerSpan.innerHTML = newHtml; + + setTimeout(() => { + const newTags = innerSpan.querySelectorAll( + '.rm-page-ref--tag:not([data-attribute-button-rendered="true"])', + ); + + newTags.forEach((tag) => { + if (tag instanceof HTMLSpanElement) { + renderNodeTagPopupButton(tag, onloadArgs.extensionAPI); + } + }); + }, 50); + } + }); + }, + }); + + const tableTagObserver = createHTMLObserver({ + tag: "TD", + className: "relative", + callback: (el: HTMLElement) => { + if (!(el instanceof HTMLTableCellElement)) return; + + const td = el; + if (!td.hasAttribute("data-cell-content")) return; + + const content = td.dataset.cellContent || ""; + if (!content.includes("#")) return; + + const discourseNodes = getDiscourseNodes(); + const discourseTagSet = new Set( + discourseNodes.map((n) => n.tag?.toLowerCase()).filter(Boolean), + ); + + const existingTags = td.querySelectorAll(".rm-page-ref--tag"); + existingTags.forEach((tag) => { + const tagName = tag.getAttribute("data-tag"); + if ( + tagName && + discourseTagSet.has(tagName.toLowerCase()) && + tag instanceof HTMLSpanElement + ) { + tag.removeAttribute("data-attribute-button-rendered"); + renderNodeTagPopupButton(tag, onloadArgs.extensionAPI); + } + }); + + const innerContainers = [ + td.querySelector("a.rm-page-ref > span"), + td.querySelector("div.rm-block__input > span"), + td.querySelector("div.rm-block__input"), + td.querySelector("div.roamjs-query-embed span"), + ].filter(Boolean); + + innerContainers.forEach((innerSpan) => { + if (!innerSpan) return; + + const formattedTagsInside = + innerSpan.querySelectorAll(".rm-page-ref--tag"); + if (formattedTagsInside.length > 0) return; + + const textContent = innerSpan.textContent || ""; + const unformattedDiscourseTagsFound = []; + + discourseNodes.forEach((node) => { + const tag = node.tag; + if (!tag) return; + + const pattern = new RegExp(`#${tag}(?![\\w-])`, "i"); + if (pattern.test(textContent)) { + const alreadyFormatted = innerSpan.querySelector( + `.rm-page-ref--tag[data-tag="${tag}"]`, + ); + if (!alreadyFormatted) { + unformattedDiscourseTagsFound.push(tag); + } + } + }); + + if (unformattedDiscourseTagsFound.length === 0) return; + + const originalHtml = innerSpan.innerHTML; + const newHtml = originalHtml.replace(/#([\w-]+)/g, (match, tagName) => { + if ( + innerSpan.querySelector(`.rm-page-ref--tag[data-tag="${tagName}"]`) + ) { + return match; + } + + if (discourseTagSet.has(tagName.toLowerCase())) { + return `${match}`; + } + + return match; + }); + + if (originalHtml !== newHtml) { + innerSpan.innerHTML = newHtml; + + setTimeout(() => { + const newTags = innerSpan.querySelectorAll( + '.rm-page-ref--tag:not([data-attribute-button-rendered="true"])', + ); + + newTags.forEach((tag) => { + if (tag instanceof HTMLSpanElement) { + renderNodeTagPopupButton(tag, onloadArgs.extensionAPI); + } + }); + }, 50); + } + }); }, }); @@ -152,6 +364,7 @@ export const initObservers = async ({ }); // refresh config tree after config page is created refreshConfigTree(); + refreshDiscourseNodeCache(); const hashChangeListener = (e: Event) => { const evt = e as HashChangeEvent; @@ -162,6 +375,7 @@ export const initObservers = async ({ getDiscourseNodes().some(({ type }) => evt.oldURL.endsWith(type)) ) { refreshConfigTree(); + refreshDiscourseNodeCache(); } }; @@ -316,6 +530,7 @@ export const initObservers = async ({ linkedReferencesObserver, graphOverviewExportObserver, nodeTagPopupButtonObserver, + tableTagObserver, ].filter((o): o is MutationObserver => !!o), listeners: { pageActionListener, diff --git a/apps/roam/src/utils/renderNodeTagPopup.tsx b/apps/roam/src/utils/renderNodeTagPopup.tsx index 460d2a08a..b1851f0cf 100644 --- a/apps/roam/src/utils/renderNodeTagPopup.tsx +++ b/apps/roam/src/utils/renderNodeTagPopup.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState, useRef, useEffect } from "react"; import ReactDOM from "react-dom"; import { Button, Popover, Position } from "@blueprintjs/core"; import { renderCreateNodeDialog } from "~/components/CreateNodeDialog"; @@ -7,29 +7,122 @@ import getUids from "roamjs-components/dom/getUids"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import getDiscourseNodes from "./getDiscourseNodes"; +const TableEmbedPopup: React.FC<{ + parent: HTMLElement; + matchedNode: any; + extensionAPI: OnloadArgs["extensionAPI"]; + blockUid?: string; + cleanedBlockText: string; +}> = ({ parent, matchedNode, extensionAPI, blockUid, cleanedBlockText }) => { + const [showPopup, setShowPopup] = useState(false); + const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 }); + const timeoutRef = useRef(); + const popupRef = useRef(null); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + const rect = parent.getBoundingClientRect(); + setPopupPosition({ + x: rect.left + rect.width / 2, + y: rect.top - 8, + }); + setShowPopup(true); + }; + + const handleMouseLeave = (e: MouseEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (relatedTarget && popupRef.current?.contains(relatedTarget)) { + return; + } + + timeoutRef.current = setTimeout(() => { + setShowPopup(false); + }, 150); + }; + + const handlePopupMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + + const handlePopupMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setShowPopup(false); + }, 100); + }; + + useEffect(() => { + parent.addEventListener("mouseenter", handleMouseEnter); + parent.addEventListener("mouseleave", handleMouseLeave); + parent.style.cursor = "pointer"; + + return () => { + parent.removeEventListener("mouseenter", handleMouseEnter); + parent.removeEventListener("mouseleave", handleMouseLeave); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + if (!showPopup) return null; + + return ReactDOM.createPortal( +
+
, + document.body, + ); +}; + export const renderNodeTagPopupButton = ( parent: HTMLSpanElement, + discourseNodes: DiscourseNode[], extensionAPI: OnloadArgs["extensionAPI"], ) => { - if (parent.dataset.attributeButtonRendered === "true") return; + if (parent.dataset.attributeButtonRendered === "true") { + return; + } parent.dataset.attributeButtonRendered = "true"; - const wrapper = document.createElement("span"); - wrapper.style.position = "relative"; - wrapper.style.display = "inline-block"; - parent.parentNode?.insertBefore(wrapper, parent); - wrapper.appendChild(parent); - - const reactRoot = document.createElement("span"); - reactRoot.style.position = "absolute"; - reactRoot.style.top = "0"; - reactRoot.style.left = "0"; - reactRoot.style.width = "100%"; - reactRoot.style.height = "100%"; - reactRoot.style.pointerEvents = "none"; - reactRoot.style.zIndex = "10"; - - wrapper.appendChild(reactRoot); + + const isInTable = !!parent.closest("td.relative"); + const isInEmbed = !!parent.closest(".roamjs-query-embed"); const textContent = parent.textContent?.trim() || ""; const tagAttr = parent.getAttribute("data-tag") || textContent; @@ -38,11 +131,15 @@ export const renderNodeTagPopupButton = ( const discourseTagSet = new Set( discourseNodes.map((n) => n.tag?.toLowerCase()).filter(Boolean), ); - if (!discourseTagSet.has(tag)) return; - const matchedNode = discourseNodes.find((n) => n.tag?.toLowerCase() === tag); + if (!discourseTagSet.has(tag)) { + return; + } - if (!matchedNode) return; + const matchedNode = discourseNodes.find((n) => n.tag?.toLowerCase() === tag); + if (!matchedNode) { + return; + } const blockInputElement = parent.closest(".rm-block__input"); const blockUid = blockInputElement @@ -52,45 +149,80 @@ export const renderNodeTagPopupButton = ( const rawBlockText = blockUid ? getTextByBlockUid(blockUid) : ""; const cleanedBlockText = rawBlockText.replace(textContent, "").trim(); - ReactDOM.render( - { - renderCreateNodeDialog({ - onClose: () => {}, - defaultNodeTypeUid: matchedNode.type, - extensionAPI, - sourceBlockUid: blockUid, - initialTitle: cleanedBlockText, - }); - }} - text={`Create ${matchedNode.text}`} - /> - } - target={ - - } - interactionKind="hover" - position={Position.TOP} - modifiers={{ - offset: { - offset: "0, 10", - }, - arrow: { - enabled: false, - }, - }} - />, - reactRoot, - ); + if (isInTable && isInEmbed) { + const reactContainer = document.createElement("div"); + reactContainer.style.display = "none"; + parent.appendChild(reactContainer); + + ReactDOM.render( + , + reactContainer, + ); + } else { + const wrapper = document.createElement("span"); + wrapper.style.position = "relative"; + wrapper.style.display = "inline-block"; + parent.parentNode?.insertBefore(wrapper, parent); + wrapper.appendChild(parent); + + const reactRoot = document.createElement("span"); + reactRoot.style.position = "absolute"; + reactRoot.style.top = "0"; + reactRoot.style.left = "0"; + reactRoot.style.width = "100%"; + reactRoot.style.height = "100%"; + reactRoot.style.pointerEvents = "none"; + reactRoot.style.zIndex = "10"; + wrapper.appendChild(reactRoot); + + ReactDOM.render( + { + renderCreateNodeDialog({ + onClose: () => {}, + defaultNodeTypeUid: matchedNode.type, + extensionAPI, + sourceBlockUid: blockUid, + initialTitle: cleanedBlockText, + }); + }} + text={`Create ${matchedNode.text}`} + /> + } + target={ + + } + interactionKind="hover" + usePortal={true} + portalClassName="dg-popover" + position={Position.TOP} + modifiers={{ + offset: { + offset: "0, 10", + }, + arrow: { + enabled: false, + }, + }} + />, + reactRoot, + ); + } };