diff --git a/README.md b/README.md index 9a5ae63..20d98b4 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ RoamJS Logo -**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.** [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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 +
+ {getBookmarkTargetLabel({ bookmark })} + {bookmark.shortcut ? ( + + {formatShortcutForDisplay({ + shortcut: bookmark.shortcut, + isMac, + })} + + ) : null} +
} multiline onClick={(): void => onOpenBookmark(bookmark)} @@ -163,8 +167,8 @@ const QuickSwitcherDialog = ({ ) : (
{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 = ({
{bookmark.title}
+ {getBookmarkTargetLabel({ bookmark })} {bookmark.shortcut ? ( {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", () => {