Repository navigation
ENG-2367 Scroll to a candidate result in context of its page in the Roam search preview #1537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: eng-2366-show-candidate-nodes-as-results-in-roam-advanced-node-search
Are you sure you want to change the base?
Changes from all commits
04bac86
0aa625b
1c94ca1
a8585bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ({ | |
| </Button> | ||
| ); | ||
|
|
||
| const IMAGE_LOAD_TIMEOUT_MS = 1000; | ||
|
|
||
| // Unloaded images reserve no height, so scrolling before they load lands off target. | ||
| const waitForImages = (el: HTMLElement): Promise<void> => { | ||
| 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<void>((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<HTMLDivElement | null>; | ||
| }): React.ReactElement => { | ||
| const hostRef = useRef<HTMLDivElement | null>(null); | ||
| const [renderedUid, setRenderedUid] = useState<string | null>(null); | ||
| const showPage = useMemo( | ||
| () => !!pageUid && !hasCollapsedAncestor(uid), | ||
| [pageUid, uid], | ||
| ); | ||
| const renderUid = showPage ? pageUid : uid; | ||
|
Comment on lines
+213
to
+217
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Moved candidates preview the wrong page When a candidate moves between indexing and selection, Learn moreCandidate results store the page UID when queryCandidatesForType builds the search index. The index persists while the dialog is open. Moving a candidate to another page during that time leaves the candidate UID valid but the stored page UID stale. The preview renders the former page, and revealBlockInPreview cannot find the candidate there. Example: Search indexes block Recommended fix: Resolve the candidate's current page UID when rendering its preview, or verify the indexed page UID still owns the block and fall back to rendering the block if it changed. Refresh the preview when that relationship changes. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| 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 <div ref={hostRef} />; | ||
| }; | ||
|
|
||
| const PreviewPane = ({ result }: { result: SearchResult | null }) => { | ||
| const scrollContainerRef = useRef<HTMLDivElement | null>(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 ( | ||
| <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden"> | ||
|
|
@@ -182,11 +294,18 @@ const PreviewPane = ({ result }: { result: SearchResult | null }) => { | |
| {result.authorName || "Unknown"} | ||
| </div> | ||
| <div | ||
| ref={scrollContainerRef} | ||
| className="min-h-0 flex-1 overflow-y-auto border-t border-gray-200 px-5 py-3" | ||
| onMouseDown={(event) => event.preventDefault()} | ||
| > | ||
| <div className="pointer-events-none"> | ||
| {isPage ? ( | ||
| {result.candidate ? ( | ||
| <CandidatePreview | ||
| uid={result.uid} | ||
| pageUid={result.candidate.pageUid} | ||
| scrollContainerRef={scrollContainerRef} | ||
| /> | ||
| ) : isPage ? ( | ||
| <RenderRoamPage hideMentions key={result.uid} uid={result.uid} /> | ||
| ) : ( | ||
| <RenderRoamBlock key={result.uid} uid={result.uid} zoomPath /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLElement>(".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(); | ||
| }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 => | ||
| `<div class="roam-block-container rm-block" data-block-uid="${uid}">` + | ||
| `<div class="rm-block-main"><div class="rm-block__input roam-block"></div></div>` + | ||
| `<div class="rm-block-children">${children}</div>` + | ||
| `</div>`; | ||
|
|
||
| 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 = | ||
| `<div class="roam-block-container rm-block" data-block-uid="host">` + | ||
| `<div class="rm-block-main"><div class="rm-block__input roam-block">` + | ||
| `<div class="rm-embed-container">${block("tagged")}</div>` + | ||
| `</div></div></div>`; | ||
| 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<HTMLElement>(".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([]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Slow images displace the candidate
When an image above the candidate loads after one second,
waitForImagesreleases the reveal early. The image shifts the page afterward, leaving the candidate off-center or outside the preview.Learn more
The candidate preview waits for images before measuring the target row and setting the scroll position. The one-second timer can resolve before an image above that row finishes loading. The later image height change shifts the row, but the reveal effect only runs once per selected UID.
Example: An image above block
b1takes two seconds to load. At one second the preview centersb1using the image's empty height; one second later the image expands by 1200 pixels and pushesb1below the visible area.Recommended fix: Keep the initial timeout for responsiveness, but remeasure and adjust the scroll when pending images finish loading, provided the same candidate remains active. Clean up image listeners and timers when switching results.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving this as is. The 1000 ms cap is deliberate and matches the Obsidian preview (ENG-2273); without it, a slow or broken image would hold the reveal indefinitely. An image that finishes after the cap can still shift the row, which is an accepted limit shared with Obsidian. Live check: an 800×1200 image above the candidate loaded inside the cap and the row stayed centred.
🤖 Addressed by Claude Code