Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
),
Comment on lines +188 to +190

Copy link
Copy Markdown
Contributor

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, waitForImages releases 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 b1 takes two seconds to load. At one second the preview centers b1 using the image's empty height; one second later the image expands by 1200 pixels and pushes b1 below 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

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

]);
};

// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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, pageUid still identifies its former page. The preview shows that page and cannot reveal the candidate.

Learn more

Candidate 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 b1 on page p1. Move b1 to page p2, then select its search row. The preview renders p1 and scrolls to its top instead of displaying b1 on p2.

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.

Devin Review


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">
Expand All @@ -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 />
Expand Down
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();
};
};
1 change: 1 addition & 0 deletions apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ const queryCandidatesForType = async ({
candidate: {
nodeTypes: [node.type],
pageTitle: pulled[":block/page"]?.[":node/title"] || "",
pageUid: pulled[":block/page"]?.[":block/uid"] || "",
},
};
})
Expand Down
17 changes: 17 additions & 0 deletions apps/roam/src/styles/discourseGraphStyles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions apps/roam/src/utils/__tests__/candidateNodeSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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",
},
},
]);
});
Expand Down
111 changes: 111 additions & 0 deletions apps/roam/src/utils/__tests__/revealBlockInPreview.test.ts
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([]);
});
});
4 changes: 2 additions & 2 deletions apps/roam/src/utils/discourseNodeSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 */

Expand Down
2 changes: 1 addition & 1 deletion apps/roam/src/utils/discourseNodeSearchTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading