diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index e8065e47f9..ec016a0251 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Button, Dialog, @@ -25,6 +31,7 @@ import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; +import { revealBlockInPreview } from "./revealBlockInPreview"; import { mountAdvancedSearchInSidebar } from "./mountAdvancedSearchInSidebar"; import { DEBOUNCE_MS, @@ -160,7 +167,112 @@ const ResultRow = ({ ); +const IMAGE_LOAD_TIMEOUT_MS = 1000; + +// Unloaded images reserve no height, so scrolling before they load lands off target. +const waitForImages = (el: HTMLElement): Promise => { + const pending = Array.from(el.querySelectorAll("img")).filter( + (img) => !img.complete, + ); + if (!pending.length) return Promise.resolve(); + return Promise.race([ + Promise.all( + pending.map( + (img) => + new Promise((resolve) => { + img.addEventListener("load", resolve, { once: true }); + img.addEventListener("error", resolve, { once: true }); + }), + ), + ).then(() => undefined), + new Promise((resolve) => + window.setTimeout(resolve, IMAGE_LOAD_TIMEOUT_MS), + ), + ]); +}; + +// Children of a collapsed block aren't in the DOM, and expanding would write +// :block/open to the user's graph. +const hasCollapsedAncestor = (uid: string): boolean => + window.roamAlphaAPI.data.fast.q( + `[:find ?parent :in $ ?uid :where [?block :block/uid ?uid] [?block :block/parents ?parent] [?parent :block/open false]]`, + uid, + ).length > 0; + +const CandidatePreview = ({ + uid, + pageUid, + scrollContainerRef, +}: { + uid: string; + pageUid: string; + scrollContainerRef: React.RefObject; +}): React.ReactElement => { + const hostRef = useRef(null); + const [renderedUid, setRenderedUid] = useState(null); + const showPage = useMemo( + () => !!pageUid && !hasCollapsedAncestor(uid), + [pageUid, uid], + ); + const renderUid = showPage ? pageUid : uid; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + let cancelled = false; + // A fresh mount node per render, so a pending unmount can't tear down the next one. + const el = document.createElement("div"); + host.appendChild(el); + setRenderedUid(null); + const { components } = window.roamAlphaAPI.ui; + const render = showPage + ? components.renderPage({ uid: renderUid, el, "hide-mentions?": true }) + : components.renderBlock({ uid: renderUid, el, "zoom-path?": true }); + void render + .then(() => waitForImages(el)) + .then(() => { + if (!cancelled) setRenderedUid(renderUid); + }) + .catch((error) => + console.error(`Failed to render search preview ${renderUid}:`, error), + ); + return () => { + cancelled = true; + el.remove(); + // Unmounting before the render settles would miss it and leave it running. + void render + .then(() => components.unmountNode({ el })) + .catch(() => undefined); + }; + }, [renderUid, showPage]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container || renderedUid !== renderUid) return; + let clearFlash = (): void => undefined; + const frame = window.requestAnimationFrame(() => { + clearFlash = revealBlockInPreview({ container, uid }); + }); + return () => { + window.cancelAnimationFrame(frame); + clearFlash(); + }; + }, [renderedUid, renderUid, uid, scrollContainerRef]); + + return
; +}; + const PreviewPane = ({ result }: { result: SearchResult | null }) => { + const scrollContainerRef = useRef(null); + const isCandidate = !!result?.candidate; + const wasCandidateRef = useRef(false); + useEffect(() => { + // Don't carry a candidate's scroll into a node preview, which never scrolled itself. + if (wasCandidateRef.current && !isCandidate && scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + wasCandidateRef.current = isCandidate; + }, [isCandidate]); if (!result) { return (
@@ -182,11 +294,18 @@ const PreviewPane = ({ result }: { result: SearchResult | null }) => { {result.authorName || "Unknown"}
event.preventDefault()} >
- {isPage ? ( + {result.candidate ? ( + + ) : isPage ? ( ) : ( diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/revealBlockInPreview.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/revealBlockInPreview.ts new file mode 100644 index 0000000000..b07422a070 --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/revealBlockInPreview.ts @@ -0,0 +1,34 @@ +export const PREVIEW_FLASH_CLASS = "dg-search-preview-flash"; + +export const revealBlockInPreview = ({ + container, + uid, +}: { + container: HTMLElement; + uid: string; +}): (() => void) => { + const block = Array.from( + container.querySelectorAll(".rm-block[data-block-uid]"), + ).find( + // Embeds render copies carrying the same uid; the real block sits outside them. + (el) => el.dataset.blockUid === uid && !el.closest(".rm-embed-container"), + ); + const row = block?.querySelector(":scope > .rm-block-main"); + if (!row) { + container.scrollTop = 0; + return () => undefined; + } + // Set scrollTop directly: scrollIntoView would also scroll Roam's main window. + const rowRect = row.getBoundingClientRect(); + container.scrollTop += + rowRect.top - + container.getBoundingClientRect().top - + (container.clientHeight - rowRect.height) / 2; + row.classList.add(PREVIEW_FLASH_CLASS); + const clear = (): void => row.classList.remove(PREVIEW_FLASH_CLASS); + row.addEventListener("animationend", clear, { once: true }); + return () => { + row.removeEventListener("animationend", clear); + clear(); + }; +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts index 48b531ea80..46eafe8fa0 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts @@ -189,6 +189,7 @@ const queryCandidatesForType = async ({ candidate: { nodeTypes: [node.type], pageTitle: pulled[":block/page"]?.[":node/title"] || "", + pageUid: pulled[":block/page"]?.[":block/uid"] || "", }, }; }) diff --git a/apps/roam/src/styles/discourseGraphStyles.css b/apps/roam/src/styles/discourseGraphStyles.css index a8451c8ec4..cd276ecff5 100644 --- a/apps/roam/src/styles/discourseGraphStyles.css +++ b/apps/roam/src/styles/discourseGraphStyles.css @@ -80,6 +80,23 @@ div.roamjs-discourse-drawer div.bp3-drawer { background: #ffff00; } +/* Matches Obsidian's search preview flash; held at full colour so the fade isn't mostly gone after the scroll. */ +.dg-search-preview-flash { + animation: dg-search-preview-flash 3s ease-out; + border-radius: 4px; +} + +@keyframes dg-search-preview-flash { + 0%, + 35% { + /* Roam's own ^^highlight^^ colour. */ + background-color: #fef09f; + } + 100% { + background-color: transparent; + } +} + .roamjs-discourse-editor-preview > .rm-api-render--block > .roam-block-container diff --git a/apps/roam/src/utils/__tests__/candidateNodeSearch.test.ts b/apps/roam/src/utils/__tests__/candidateNodeSearch.test.ts index d10f8b9eb3..0f3139453f 100644 --- a/apps/roam/src/utils/__tests__/candidateNodeSearch.test.ts +++ b/apps/roam/src/utils/__tests__/candidateNodeSearch.test.ts @@ -51,7 +51,7 @@ const pulledPage = (uid: string, title: string): PulledDiscourseNode => ({ const pulledBlock = (uid: string, text: string): PulledDiscourseNode => ({ ":block/uid": uid, ":block/string": text, - ":block/page": { ":node/title": "Field notes" }, + ":block/page": { ":node/title": "Field notes", ":block/uid": "fieldNotes" }, ":create/time": 3, ":edit/time": 4, }); @@ -160,13 +160,21 @@ describe("buildSearchIndex", () => { uid: "b1", type: "clm", title: "Soil moisture drives yield", - candidate: { nodeTypes: ["clm"], pageTitle: "Field notes" }, + candidate: { + nodeTypes: ["clm"], + pageTitle: "Field notes", + pageUid: "fieldNotes", + }, }, { uid: "b2", type: "clm", title: "shared block", - candidate: { nodeTypes: ["clm", "evd"], pageTitle: "Field notes" }, + candidate: { + nodeTypes: ["clm", "evd"], + pageTitle: "Field notes", + pageUid: "fieldNotes", + }, }, ]); }); diff --git a/apps/roam/src/utils/__tests__/revealBlockInPreview.test.ts b/apps/roam/src/utils/__tests__/revealBlockInPreview.test.ts new file mode 100644 index 0000000000..0a073b6bd1 --- /dev/null +++ b/apps/roam/src/utils/__tests__/revealBlockInPreview.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { + PREVIEW_FLASH_CLASS, + revealBlockInPreview, +} from "~/components/AdvancedNodeSearchDialog/revealBlockInPreview"; + +// Mirrors the markup Roam's renderPage emits for one block and its children. +const block = (uid: string, children = ""): string => + `
` + + `
` + + `
${children}
` + + `
`; + +const mount = (html: string): HTMLElement => { + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + return container; +}; + +const flashed = (root: ParentNode): string[] => + Array.from(root.querySelectorAll(`.${PREVIEW_FLASH_CLASS}`)).map( + (el) => el.closest(".rm-block")?.getAttribute("data-block-uid") ?? "", + ); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("revealBlockInPreview", () => { + it("flashes only the tagged block's own row, not its children", () => { + const container = mount(block("parent", block("tagged", block("child")))); + + revealBlockInPreview({ container, uid: "tagged" }); + + const row = container.querySelector( + '[data-block-uid="tagged"] > .rm-block-main', + ); + expect(row?.classList.contains(PREVIEW_FLASH_CLASS)).toBe(true); + expect(flashed(container)).toEqual(["tagged"]); + }); + + it("skips an embedded copy of the block that renders above the real one", () => { + const embedHost = + `
` + + `
` + + `
${block("tagged")}
` + + `
`; + const container = mount(embedHost + block("tagged")); + + revealBlockInPreview({ container, uid: "tagged" }); + + const flashedRows = container.querySelectorAll(`.${PREVIEW_FLASH_CLASS}`); + expect(flashedRows).toHaveLength(1); + expect(flashedRows[0]?.closest(".rm-embed-container")).toBeNull(); + }); + + it("centres the tagged row within the preview's own scroll area", () => { + const container = mount(block("tagged")); + const row = container.querySelector(".rm-block-main")!; + // jsdom has no layout, so stub the geometry the browser would report. + Object.defineProperty(container, "clientHeight", { value: 400 }); + container.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; + row.getBoundingClientRect = () => ({ top: 700, height: 40 }) as DOMRect; + container.scrollTop = 50; + + revealBlockInPreview({ container, uid: "tagged" }); + + expect(container.scrollTop).toBe(470); + }); + + it("clears the flash when switching away before it finishes", () => { + const container = mount(block("tagged")); + + const cleanup = revealBlockInPreview({ container, uid: "tagged" }); + cleanup(); + + expect(flashed(container)).toEqual([]); + }); + + it("clears the flash once its animation ends", () => { + const container = mount(block("tagged")); + + revealBlockInPreview({ container, uid: "tagged" }); + container + .querySelector(".rm-block-main")! + .dispatchEvent(new Event("animationend")); + + expect(flashed(container)).toEqual([]); + }); + + it("ignores a copy of the block rendered outside the preview", () => { + mount(block("tagged")); + const container = mount(block("other")); + + revealBlockInPreview({ container, uid: "tagged" }); + + expect(flashed(document.body)).toEqual([]); + }); + + it("scrolls back to the top without flashing when the block isn't rendered", () => { + const container = mount(block("parent")); + container.scrollTop = 240; + + revealBlockInPreview({ container, uid: "underCollapsedParent" }); + + expect(container.scrollTop).toBe(0); + expect(flashed(container)).toEqual([]); + }); +}); diff --git a/apps/roam/src/utils/discourseNodeSearch.ts b/apps/roam/src/utils/discourseNodeSearch.ts index c76448bf32..c8da481d58 100644 --- a/apps/roam/src/utils/discourseNodeSearch.ts +++ b/apps/roam/src/utils/discourseNodeSearch.ts @@ -15,7 +15,7 @@ export const BASIC_DISCOURSE_NODE_PULL = export const DISCOURSE_NODE_SEARCH_METADATA_PULL = `[:block/string :node/title :block/uid :create/time :edit/time {:create/user [:user/display-name :user/email]} {:edit/user [:user/display-name :user/email]}]`; -export const CANDIDATE_BLOCK_SEARCH_PULL = `[:block/string :block/uid :create/time :edit/time {:create/user [:user/display-name :user/email]} {:edit/user [:user/display-name :user/email]} {:block/page [:node/title]}]`; +export const CANDIDATE_BLOCK_SEARCH_PULL = `[:block/string :block/uid :create/time :edit/time {:create/user [:user/display-name :user/email]} {:edit/user [:user/display-name :user/email]} {:block/page [:node/title :block/uid]}]`; /* eslint-disable @typescript-eslint/naming-convention */ type PulledDiscourseUser = { @@ -38,7 +38,7 @@ export type PulledDiscourseNode = { ":edit/time"?: string | number; ":create/user"?: PulledDiscourseUser; ":edit/user"?: PulledDiscourseUser; - ":block/page"?: { ":node/title"?: string }; + ":block/page"?: { ":node/title"?: string; ":block/uid"?: string }; }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/apps/roam/src/utils/discourseNodeSearchTypes.ts b/apps/roam/src/utils/discourseNodeSearchTypes.ts index 4b54e9b711..875562acf3 100644 --- a/apps/roam/src/utils/discourseNodeSearchTypes.ts +++ b/apps/roam/src/utils/discourseNodeSearchTypes.ts @@ -13,7 +13,7 @@ export type SearchResult = { lastModified: string; authorName: string; // Set for a block tagged with a node type's candidate tag, not a node page. - candidate?: { nodeTypes: string[]; pageTitle: string }; + candidate?: { nodeTypes: string[]; pageTitle: string; pageUid: string }; }; // score and source are carried for diagnostics and future rank fusion; ordering