diff --git a/README.md b/README.md
index 9a5ae63..20d98b4 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,13 @@
-**Jump to saved pages from a searchable switcher, direct keyboard shortcuts, and quick-open workflows built for fast graph navigation.**
+**Jump to saved pages and blocks from a searchable switcher, direct keyboard shortcuts, and quick-open workflows built for fast graph navigation.**
[](https://deepwiki.com/RoamJS/quick-switcher)
## Features
-- Save frequently used pages as Quick Switcher entries.
-- Open saved pages from a focused searchable dialog.
-- Assign direct keyboard shortcuts for one-step page switching.
-- Reorder and remove saved pages from extension settings.
+- Save frequently used pages and blocks as Quick Switcher entries.
+- Open saved pages and blocks from a focused searchable dialog.
+- Assign direct keyboard shortcuts for one-step switching.
+- Reorder and remove saved entries from extension settings.
diff --git a/src/components/QuickSwitcherDialog.tsx b/src/components/QuickSwitcherDialog.tsx
index 3819a7b..4a20a13 100644
--- a/src/components/QuickSwitcherDialog.tsx
+++ b/src/components/QuickSwitcherDialog.tsx
@@ -10,6 +10,7 @@ import type { QuickSwitcherBookmark } from "~/types/quickSwitcher";
import {
filterBookmarks,
formatShortcutForDisplay,
+ getBookmarkTargetLabel,
} from "~/utils/quickSwitcher";
type QuickSwitcherDialogProps = {
@@ -21,7 +22,7 @@ type QuickSwitcherDialogProps = {
};
const getResultCountLabel = ({ count }: { count: number }): string =>
- `${count} ${count === 1 ? "bookmark" : "bookmarks"}`;
+ `${count} ${count === 1 ? "entry" : "entries"}`;
const QuickSwitcherDialog = ({
isOpen,
@@ -124,7 +125,7 @@ const QuickSwitcherDialog = ({
setQuery(event.target.value)
}
onKeyDown={onInputKeyDown}
- placeholder="Search bookmarked pages"
+ placeholder="Search saved pages and blocks"
value={query}
/>
@@ -137,14 +138,17 @@ const QuickSwitcherDialog = ({
icon="document-open"
key={bookmark.id}
labelElement={
- bookmark.shortcut ? (
-
- {formatShortcutForDisplay({
- shortcut: bookmark.shortcut,
- isMac,
- })}
-
- ) : undefined
+
{query.trim()
- ? "No bookmarked pages match this search."
- : "No bookmarked pages yet. Add pages in Quick Switcher settings."}
+ ? "No saved entries match this search."
+ : "No saved entries yet. Add pages or blocks in Quick Switcher settings."}
)}
diff --git a/src/components/QuickSwitcherSettings.tsx b/src/components/QuickSwitcherSettings.tsx
index 7e68211..fa517a1 100644
--- a/src/components/QuickSwitcherSettings.tsx
+++ b/src/components/QuickSwitcherSettings.tsx
@@ -20,7 +20,12 @@ import type {
import {
buildRoamPageUrl,
createBookmarkId,
+ deriveBlockTitle,
+ extractBlockRefUid,
formatShortcutForDisplay,
+ getBookmarkTargetLabel,
+ getBookmarkTargetType,
+ getBookmarkTargetUid,
keyboardEventToShortcut,
moveBookmarkByOffset,
normalizeShortcut,
@@ -58,6 +63,40 @@ const isPageUrlInput = ({ entry }: { entry: string }): boolean =>
entry.startsWith("#/") ||
entry.startsWith("/#/");
+const getBlockUidFromInput = ({ value }: { value: string }): string => {
+ const normalizedValue = value.trim();
+ return (
+ extractBlockRefUid({ value: normalizedValue }) ||
+ (isPageUrlInput({ entry: normalizedValue })
+ ? parsePageUidFromUrl({ url: normalizedValue })
+ : normalizedValue) ||
+ ""
+ );
+};
+
+const getBlockByUid = ({
+ blockUid,
+}: {
+ blockUid: string;
+}): { uid: string; text: string } | null => {
+ const block = window.roamAlphaAPI.pull(
+ "[:block/uid :block/string :node/title]",
+ [":block/uid", blockUid],
+ ) as {
+ ":block/uid"?: string;
+ ":node/title"?: string;
+ ":block/string"?: string;
+ } | null;
+ const uid = block?.[":block/uid"] || "";
+ if (!uid || block?.[":node/title"]) {
+ return null;
+ }
+ return {
+ uid,
+ text: block?.[":block/string"] || "",
+ };
+};
+
export const createQuickSwitcherSettingsComponent = ({
initialBookmarks,
initialQuerySource,
@@ -78,6 +117,7 @@ export const createQuickSwitcherSettingsComponent = ({
);
const [isManageDialogOpen, setIsManageDialogOpen] = useState(false);
const [pageTitle, setPageTitle] = useState("");
+ const [blockRef, setBlockRef] = useState("");
const [shortcut, setShortcut] = useState("");
const [bulkPages, setBulkPages] = useState("");
@@ -106,6 +146,7 @@ export const createQuickSwitcherSettingsComponent = ({
const clearForm = (): void => {
setPageTitle("");
+ setBlockRef("");
setShortcut("");
};
@@ -145,6 +186,43 @@ export const createQuickSwitcherSettingsComponent = ({
setShortcut(nextShortcut);
};
+ const getValidatedShortcut = (): string | null | undefined => {
+ const normalizedShortcut = shortcut
+ ? normalizeShortcut({ shortcut })
+ : null;
+ if (shortcut && !normalizedShortcut) {
+ showToast({
+ content: "Capture a valid shortcut or leave it blank",
+ intent: "warning",
+ });
+ return undefined;
+ }
+
+ if (
+ normalizedShortcut &&
+ !shortcutHasModifier({ shortcut: normalizedShortcut })
+ ) {
+ showToast({
+ content: "Shortcut must include at least one modifier key",
+ intent: "warning",
+ });
+ return undefined;
+ }
+
+ const existingShortcut = normalizedShortcut
+ ? bookmarks.find((bookmark) => bookmark.shortcut === normalizedShortcut)
+ : null;
+ if (existingShortcut) {
+ showToast({
+ content: `Shortcut already used by "${existingShortcut.title}"`,
+ intent: "warning",
+ });
+ return undefined;
+ }
+
+ return normalizedShortcut;
+ };
+
const addBookmark = (): void => {
const normalizedPageTitle = pageTitle.trim();
if (!normalizedPageTitle) {
@@ -164,56 +242,97 @@ export const createQuickSwitcherSettingsComponent = ({
return;
}
- const normalizedShortcut = shortcut
- ? normalizeShortcut({ shortcut })
- : null;
- if (shortcut && !normalizedShortcut) {
+ const existingPage = bookmarks.find(
+ (bookmark) =>
+ getBookmarkTargetType({ bookmark }) === "page" &&
+ getBookmarkTargetUid({ bookmark }) === pageUid,
+ );
+ if (existingPage) {
showToast({
- content: "Capture a valid shortcut or leave it blank",
+ content: `"${existingPage.title}" is already bookmarked`,
intent: "warning",
});
return;
}
- if (
- normalizedShortcut &&
- !shortcutHasModifier({ shortcut: normalizedShortcut })
- ) {
+ const normalizedShortcut = getValidatedShortcut();
+ if (normalizedShortcut === undefined) {
+ return;
+ }
+
+ const resolvedTitle =
+ getPageTitleByPageUid(pageUid) || normalizedPageTitle;
+ const url = buildRoamPageUrl({ pageUid });
+ if (!url) {
showToast({
- content: "Shortcut must include at least one modifier key",
+ content: "Could not resolve a URL for this page",
+ intent: "danger",
+ });
+ return;
+ }
+
+ const nextBookmarks = [
+ ...bookmarks,
+ {
+ id: createBookmarkId(),
+ title: resolvedTitle,
+ targetType: "page",
+ pageUid,
+ blockUid: null,
+ url,
+ shortcut: normalizedShortcut,
+ },
+ ];
+
+ setAndPersistBookmarks({ nextBookmarks });
+ clearForm();
+ showToast({
+ content: "Page added",
+ intent: "success",
+ });
+ };
+
+ const addBlock = (): void => {
+ const blockUid = getBlockUidFromInput({ value: blockRef });
+ if (!blockUid) {
+ showToast({
+ content: "Paste a Roam block UID or block reference first",
intent: "warning",
});
return;
}
- const existingPage = bookmarks.find(
- (bookmark) => bookmark.pageUid === pageUid,
- );
- if (existingPage) {
+ const block = getBlockByUid({ blockUid });
+ if (!block) {
showToast({
- content: `"${existingPage.title}" is already bookmarked`,
+ content: "That block does not exist in this graph",
intent: "warning",
});
return;
}
- const existingShortcut = normalizedShortcut
- ? bookmarks.find((bookmark) => bookmark.shortcut === normalizedShortcut)
- : null;
- if (existingShortcut) {
+ const existingBlock = bookmarks.find(
+ (bookmark) =>
+ getBookmarkTargetType({ bookmark }) === "block" &&
+ getBookmarkTargetUid({ bookmark }) === blockUid,
+ );
+ if (existingBlock) {
showToast({
- content: `Shortcut already used by "${existingShortcut.title}"`,
+ content: `"${existingBlock.title}" is already bookmarked`,
intent: "warning",
});
return;
}
- const resolvedTitle =
- getPageTitleByPageUid(pageUid) || normalizedPageTitle;
- const url = buildRoamPageUrl({ pageUid });
+ const normalizedShortcut = getValidatedShortcut();
+ if (normalizedShortcut === undefined) {
+ return;
+ }
+
+ const url = buildRoamPageUrl({ pageUid: blockUid });
if (!url) {
showToast({
- content: "Could not resolve a URL for this page",
+ content: "Could not resolve a URL for this block",
intent: "danger",
});
return;
@@ -223,8 +342,10 @@ export const createQuickSwitcherSettingsComponent = ({
...bookmarks,
{
id: createBookmarkId(),
- title: resolvedTitle,
- pageUid,
+ title: deriveBlockTitle({ text: block.text }),
+ targetType: "block" as const,
+ pageUid: null,
+ blockUid: block.uid,
url,
shortcut: normalizedShortcut,
},
@@ -233,7 +354,7 @@ export const createQuickSwitcherSettingsComponent = ({
setAndPersistBookmarks({ nextBookmarks });
clearForm();
showToast({
- content: "Bookmark added",
+ content: "Block added",
intent: "success",
});
};
@@ -253,7 +374,8 @@ export const createQuickSwitcherSettingsComponent = ({
const existingPageUids = new Set(
bookmarks
- .map((bookmark) => bookmark.pageUid)
+ .filter((bookmark) => getBookmarkTargetType({ bookmark }) === "page")
+ .map((bookmark) => getBookmarkTargetUid({ bookmark }))
.filter((uid): uid is string => Boolean(uid)),
);
const nextBookmarks = [...bookmarks];
@@ -281,7 +403,9 @@ export const createQuickSwitcherSettingsComponent = ({
nextBookmarks.push({
id: createBookmarkId(),
title,
+ targetType: "page",
pageUid,
+ blockUid: null,
url,
shortcut: null,
});
@@ -334,7 +458,7 @@ export const createQuickSwitcherSettingsComponent = ({
icon="edit"
intent="primary"
onClick={(): void => setIsManageDialogOpen(true)}
- text="Manage Pages"
+ text="Manage Entries"
/>
@@ -348,6 +472,7 @@ export const createQuickSwitcherSettingsComponent = ({
{formatShortcutForDisplay({
@@ -372,7 +497,7 @@ export const createQuickSwitcherSettingsComponent = ({
isOpen={isManageDialogOpen}
onClose={closeManageDialog}
style={{ maxWidth: "95vw", width: 720 }}
- title="Manage Quick Switcher Pages"
+ title="Manage Quick Switcher Entries"
>
+
+ ): void =>
+ setBlockRef(event.target.value)
+ }
+ placeholder="((abc123def)) or abc123def"
+ value={blockRef}
+ />
+
+
-
+
+
@@ -488,6 +623,7 @@ export const createQuickSwitcherSettingsComponent = ({
+ {getBookmarkTargetLabel({ bookmark })}
{bookmark.shortcut ? (
{formatShortcutForDisplay({
diff --git a/src/index.ts b/src/index.ts
index 8334c94..c1c2ff6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,10 +31,10 @@ export default runExtension(async ({ extensionAPI }) => {
},
},
{
- id: "bookmarked-pages",
- name: "Bookmarked Pages",
+ id: "bookmarked-entries",
+ name: "Saved Entries",
description:
- "Add Roam pages with optional shortcuts and optional Query Builder pages.",
+ "Add Roam pages or blocks with optional shortcuts and optional Query Builder pages.",
action: {
type: "reactComponent",
component: settingsComponent,
diff --git a/src/quickSwitcher.tsx b/src/quickSwitcher.tsx
index b4b9492..b0dc84e 100644
--- a/src/quickSwitcher.tsx
+++ b/src/quickSwitcher.tsx
@@ -14,6 +14,8 @@ import {
buildRoamPageUrl,
extractBlockRefUid,
extractQueryBlockLabel,
+ getBookmarkTargetType,
+ getBookmarkTargetUid,
keyboardEventToShortcut,
normalizeQuerySource,
normalizeShortcut,
@@ -90,7 +92,7 @@ const sanitizeBookmarks = ({
bookmarks: QuickSwitcherBookmark[];
}): QuickSwitcherBookmark[] => {
const seenShortcuts = new Set();
- const seenUrls = new Set();
+ const seenTargets = new Set();
return bookmarks.reduce((result, bookmark) => {
const normalizedShortcut = bookmark.shortcut
@@ -102,9 +104,28 @@ const sanitizeBookmarks = ({
if (!normalizedUrl || (bookmark.shortcut && !normalizedShortcut)) {
return result;
}
+ const targetType = getBookmarkTargetType({ bookmark });
+ const parsedUrlUid = parsePageUidFromUrl({ url: normalizedUrl });
+ const pageUid =
+ targetType === "page"
+ ? bookmark.pageUid || parsedUrlUid
+ : bookmark.pageUid || null;
+ const blockUid =
+ targetType === "block"
+ ? bookmark.blockUid || parsedUrlUid
+ : bookmark.blockUid || null;
+ if (targetType === "block" && !blockUid) {
+ return result;
+ }
+ const targetKey =
+ targetType === "block" && blockUid
+ ? `block:${blockUid}`
+ : pageUid
+ ? `page:${pageUid}`
+ : `url:${normalizedUrl}`;
if (
(normalizedShortcut && seenShortcuts.has(normalizedShortcut)) ||
- seenUrls.has(normalizedUrl)
+ seenTargets.has(targetKey)
) {
return result;
}
@@ -112,13 +133,15 @@ const sanitizeBookmarks = ({
if (normalizedShortcut) {
seenShortcuts.add(normalizedShortcut);
}
- seenUrls.add(normalizedUrl);
+ seenTargets.add(targetKey);
result.push({
...bookmark,
url: normalizedUrl,
+ targetType,
shortcut: normalizedShortcut,
- pageUid: bookmark.pageUid || parsePageUidFromUrl({ url: normalizedUrl }),
+ pageUid,
+ blockUid,
});
return result;
}, []);
@@ -129,12 +152,18 @@ const openBookmark = async ({
}: {
bookmark: QuickSwitcherBookmark;
}): Promise => {
- const pageUid =
- bookmark.pageUid || parsePageUidFromUrl({ url: bookmark.url });
+ const targetType = getBookmarkTargetType({ bookmark });
+ const targetUid = getBookmarkTargetUid({ bookmark });
try {
- if (pageUid) {
+ if (targetType === "block" && targetUid) {
+ await window.roamAlphaAPI.ui.mainWindow.openBlock({
+ block: { uid: targetUid },
+ });
+ return true;
+ }
+ if (targetUid) {
await window.roamAlphaAPI.ui.mainWindow.openPage({
- page: { uid: pageUid },
+ page: { uid: targetUid },
});
return true;
}
@@ -149,8 +178,11 @@ const openBookmark = async ({
}
};
-const getBookmarkKey = ({ bookmark }: { bookmark: QuickSwitcherBookmark }) =>
- bookmark.pageUid ? `page:${bookmark.pageUid}` : `url:${bookmark.url}`;
+const getBookmarkKey = ({ bookmark }: { bookmark: QuickSwitcherBookmark }) => {
+ const targetType = getBookmarkTargetType({ bookmark });
+ const targetUid = getBookmarkTargetUid({ bookmark });
+ return targetUid ? `${targetType}:${targetUid}` : `url:${bookmark.url}`;
+};
const mergeBookmarks = ({
savedBookmarks,
@@ -320,7 +352,9 @@ const resolveQueryBuilderPageBookmarks = async ({
bookmarks.push({
id: `query-builder-${pageUid}`,
title,
+ targetType: "page",
pageUid,
+ blockUid: null,
url,
shortcut: null,
source: "query-builder",
diff --git a/src/types/quickSwitcher.ts b/src/types/quickSwitcher.ts
index c8a4d0c..b886677 100644
--- a/src/types/quickSwitcher.ts
+++ b/src/types/quickSwitcher.ts
@@ -1,8 +1,12 @@
+export type QuickSwitcherTargetType = "page" | "block";
+
export type QuickSwitcherBookmark = {
id: string;
title: string;
url: string;
+ targetType: QuickSwitcherTargetType;
pageUid: string | null;
+ blockUid: string | null;
shortcut: string | null;
source?: "saved" | "query-builder";
};
diff --git a/src/utils/quickSwitcher.ts b/src/utils/quickSwitcher.ts
index db5e390..01bee08 100644
--- a/src/utils/quickSwitcher.ts
+++ b/src/utils/quickSwitcher.ts
@@ -1,6 +1,7 @@
import type {
QuickSwitcherBookmark,
QuickSwitcherQuerySource,
+ QuickSwitcherTargetType,
ShortcutKeyboardEvent,
} from "~/types/quickSwitcher";
@@ -340,6 +341,50 @@ export const derivePageTitleFromUrl = ({ url }: { url: string }): string => {
return getLastSegmentFromPath({ path: parsedUrl.pathname });
};
+export const getBookmarkTargetType = ({
+ bookmark,
+}: {
+ bookmark: QuickSwitcherBookmark;
+}): QuickSwitcherTargetType =>
+ bookmark.targetType === "block" || bookmark.blockUid ? "block" : "page";
+
+export const getBookmarkTargetUid = ({
+ bookmark,
+}: {
+ bookmark: QuickSwitcherBookmark;
+}): string | null => {
+ const targetType = getBookmarkTargetType({ bookmark });
+ if (targetType === "block") {
+ return bookmark.blockUid || parsePageUidFromUrl({ url: bookmark.url });
+ }
+ return bookmark.pageUid || parsePageUidFromUrl({ url: bookmark.url });
+};
+
+export const getBookmarkTargetLabel = ({
+ bookmark,
+}: {
+ bookmark: QuickSwitcherBookmark;
+}): string =>
+ getBookmarkTargetType({ bookmark }) === "block" ? "Block" : "Page";
+
+export const deriveBlockTitle = ({
+ text,
+ maxWords = 8,
+}: {
+ text: string;
+ maxWords?: number;
+}): string => {
+ const normalizedText = text.replace(/\s+/g, " ").trim();
+ if (!normalizedText) {
+ return "Untitled block";
+ }
+ const words = normalizedText.split(" ");
+ if (words.length <= maxWords) {
+ return normalizedText;
+ }
+ return `${words.slice(0, maxWords).join(" ")}...`;
+};
+
export const filterBookmarks = ({
bookmarks,
query,
@@ -410,10 +455,19 @@ const parseStoredBookmark = ({
return null;
}
- const pageUid =
- typeof value.pageUid === "string"
- ? value.pageUid
- : parsePageUidFromUrl({ url });
+ const pageUid = typeof value.pageUid === "string" ? value.pageUid.trim() : "";
+ const blockUid =
+ typeof value.blockUid === "string" ? value.blockUid.trim() : "";
+ const parsedUrlUid = parsePageUidFromUrl({ url });
+ const targetType =
+ value.targetType === "block" || blockUid ? "block" : "page";
+ const resolvedPageUid =
+ targetType === "page" ? pageUid || parsedUrlUid : pageUid;
+ const resolvedBlockUid =
+ targetType === "block" ? blockUid || parsedUrlUid || pageUid : blockUid;
+ if (targetType === "block" && !resolvedBlockUid) {
+ return null;
+ }
const id =
typeof value.id === "string" && value.id.trim()
? value.id
@@ -423,7 +477,9 @@ const parseStoredBookmark = ({
id,
title,
url,
- pageUid: pageUid || null,
+ targetType,
+ pageUid: resolvedPageUid || null,
+ blockUid: resolvedBlockUid || null,
shortcut: normalizedShortcut,
};
};
diff --git a/tests/quickSwitcher.test.ts b/tests/quickSwitcher.test.ts
index 729834f..df211df 100644
--- a/tests/quickSwitcher.test.ts
+++ b/tests/quickSwitcher.test.ts
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import type { QuickSwitcherBookmark } from "../src/types/quickSwitcher";
import {
buildRoamPageUrl,
+ deriveBlockTitle,
extractBlockRefUid,
extractQueryBlockLabel,
filterBookmarks,
@@ -89,14 +90,18 @@ test("filters bookmarks by title or url", () => {
id: "1",
title: "Project Alpha",
url: "https://roamresearch.com/#/app/graph/page/alpha",
+ targetType: "page",
pageUid: "alpha",
+ blockUid: null,
shortcut: "ctrl+1",
},
{
id: "2",
title: "Research Notes",
url: "https://roamresearch.com/#/app/graph/page/research",
+ targetType: "page",
pageUid: "research",
+ blockUid: null,
shortcut: "ctrl+2",
},
];
@@ -112,21 +117,27 @@ test("moves bookmarks while preserving relative order", () => {
id: "1",
title: "One",
url: "https://roamresearch.com/#/app/graph/page/one",
+ targetType: "page",
pageUid: "one",
+ blockUid: null,
shortcut: "ctrl+1",
},
{
id: "2",
title: "Two",
url: "https://roamresearch.com/#/app/graph/page/two",
+ targetType: "page",
pageUid: "two",
+ blockUid: null,
shortcut: "ctrl+2",
},
{
id: "3",
title: "Three",
url: "https://roamresearch.com/#/app/graph/page/three",
+ targetType: "page",
pageUid: "three",
+ blockUid: null,
shortcut: "ctrl+3",
},
];
@@ -151,12 +162,19 @@ test("parses and sanitizes stored bookmarks", () => {
},
{
id: "id-3",
+ title: "A saved block entry with a title",
+ targetType: "block",
+ blockUid: "block-uid",
+ url: "https://roamresearch.com/#/app/graph/page/block-uid",
+ },
+ {
+ id: "id-4",
title: "Invalid Shortcut",
url: "https://roamresearch.com/#/app/graph/page/invalid-shortcut",
shortcut: "Ctrl +",
},
{
- id: "id-4",
+ id: "id-5",
title: "Invalid",
url: "",
shortcut: "Ctrl + 2",
@@ -164,9 +182,22 @@ test("parses and sanitizes stored bookmarks", () => {
],
});
- expect(parsed).toHaveLength(2);
+ expect(parsed).toHaveLength(3);
+ expect(parsed[0].targetType).toBe("page");
expect(parsed[0].shortcut).toBe("ctrl+1");
expect(parsed[1].shortcut).toBeNull();
+ expect(parsed[2].targetType).toBe("block");
+ expect(parsed[2].blockUid).toBe("block-uid");
+});
+
+test("derives block titles from the first few words", () => {
+ expect(
+ deriveBlockTitle({
+ text: "One two three four five six seven eight nine ten",
+ maxWords: 4,
+ }),
+ ).toBe("One two three four...");
+ expect(deriveBlockTitle({ text: " " })).toBe("Untitled block");
});
test("parses and normalizes query builder source settings", () => {