From 0a88be77711648de5a96e6af0b55b93cd2cfddf3 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Thu, 2 Nov 2023 19:49:49 +0800 Subject: [PATCH] feat(core): add jump to block for cmdk (#4802) --- .../src/components/page-detail-editor.tsx | 32 +++++++++ .../core/src/components/pure/cmdk/data.tsx | 38 ++++++++--- .../core/src/hooks/use-navigate-helper.ts | 23 +++++-- tests/affine-local/e2e/quick-search.spec.ts | 67 ++++++++++++++++++- 4 files changed, 145 insertions(+), 15 deletions(-) diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 9e85cf1cdc..4102327d6f 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -25,8 +25,10 @@ import { useEffect, useMemo, useRef, + useState, } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useLocation } from 'react-router-dom'; import { pageSettingFamily } from '../atoms'; import { fontStyleOptions } from '../atoms/settings'; @@ -38,6 +40,10 @@ import * as styles from './page-detail-editor.css'; import { editorContainer, pluginContainer } from './page-detail-editor.css'; import { TrashButtonGroup } from './pure/trash-button-group'; +function useRouterHash() { + return useLocation().hash.substring(1); +} + export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void; export interface PageDetailEditorProps { @@ -65,8 +71,10 @@ const EditorWrapper = memo(function EditorWrapper({ const meta = useBlockSuitePageMeta(workspace).find( meta => meta.id === pageId ); + const { switchToEdgelessMode, switchToPageMode } = useBlockSuiteMetaHelper(workspace); + const pageSettingAtom = pageSettingFamily(pageId); const pageSetting = useAtomValue(pageSettingAtom); const currentMode = pageSetting?.mode ?? 'page'; @@ -83,6 +91,29 @@ const EditorWrapper = memo(function EditorWrapper({ return fontStyle.value; }, [appSettings.fontStyle]); + const [loading, setLoading] = useState(true); + const blockId = useRouterHash(); + const blockElement = useMemo(() => { + if (!blockId || loading) { + return null; + } + return document.querySelector(`[data-block-id="${blockId}"]`); + }, [blockId, loading]); + + useEffect(() => { + if (blockElement) { + setTimeout( + () => + blockElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }), + 0 + ); + } + }, [blockElement]); + const setEditorMode = useCallback( (mode: 'page' | 'edgeless') => { if (mode === 'edgeless') { @@ -153,6 +184,7 @@ const EditorWrapper = memo(function EditorWrapper({ window.setTimeout(() => { disposes.forEach(dispose => dispose()); }); + setLoading(false); }; }, [onLoad] diff --git a/packages/frontend/core/src/components/pure/cmdk/data.tsx b/packages/frontend/core/src/components/pure/cmdk/data.tsx index a99dab0c94..9fe2561f9b 100644 --- a/packages/frontend/core/src/components/pure/cmdk/data.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/data.tsx @@ -39,6 +39,11 @@ import { WorkspaceSubPath } from '../../../shared'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; import type { CMDKCommand, CommandContext } from './types'; +interface SearchResultsValue { + space: string; + content: string; +} + export const cmdkQueryAtom = atom(''); export const cmdkValueAtom = atom(''); @@ -153,7 +158,8 @@ export const pageToCommand = ( label?: { title: string; subTitle?: string; - } + }, + blockId?: string ): CMDKCommand => { const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode; const currentWorkspaceId = store.get(currentWorkspaceIdAtom); @@ -177,7 +183,14 @@ export const pageToCommand = ( console.error('current workspace not found'); return; } - navigationHelper.jumpToPage(currentWorkspaceId, page.id); + if (blockId) { + return navigationHelper.jumpToPageBlock( + currentWorkspaceId, + page.id, + blockId + ); + } + return navigationHelper.jumpToPage(currentWorkspaceId, page.id); }, icon: pageMode === 'edgeless' ? : , timestamp: page.updatedDate, @@ -205,17 +218,23 @@ export const usePageCommands = () => { }); } else { // queried pages that has matched contents - const searchResults = Array.from( - workspace.blockSuiteWorkspace.search({ query }).values() - ) as unknown as { space: string; content: string }[]; + // TODO: we shall have a debounce for global search here + const searchResults = workspace.blockSuiteWorkspace.search({ + query, + }) as unknown as Map; + const resultValues = Array.from(searchResults.values()); - const pageIds = searchResults.map(result => { + const pageIds = resultValues.map(result => { if (result.space.startsWith('space:')) { return result.space.slice(6); } else { return result.space; } }); + const reverseMapping: Map = new Map(); + searchResults.forEach((value, key) => { + reverseMapping.set(value.space, key); + }); results = pages.map(page => { const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode; @@ -225,17 +244,20 @@ export const usePageCommands = () => { const label = { title: page.title || t['Untitled'](), // Used to ensure that a title exists subTitle: - searchResults.find(result => result.space === page.id)?.content || + resultValues.find(result => result.space === page.id)?.content || '', }; + const blockId = reverseMapping.get(page.id); + const command = pageToCommand( category, page, store, navigationHelper, t, - label + label, + blockId ); if (pageIds.includes(page.id)) { diff --git a/packages/frontend/core/src/hooks/use-navigate-helper.ts b/packages/frontend/core/src/hooks/use-navigate-helper.ts index 0ef61cca97..fb58c2c8cf 100644 --- a/packages/frontend/core/src/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/hooks/use-navigate-helper.ts @@ -29,6 +29,19 @@ export function useNavigateHelper() { }, [navigate] ); + const jumpToPageBlock = useCallback( + ( + workspaceId: string, + pageId: string, + blockId: string, + logic: RouteLogic = RouteLogic.PUSH + ) => { + return navigate(`/workspace/${workspaceId}/${pageId}#${blockId}`, { + replace: logic === RouteLogic.REPLACE, + }); + }, + [navigate] + ); const jumpToCollection = useCallback( ( workspaceId: string, @@ -122,6 +135,7 @@ export function useNavigateHelper() { return useMemo( () => ({ jumpToPage, + jumpToPageBlock, jumpToPublicWorkspacePage, jumpToSubPath, jumpToIndex, @@ -132,14 +146,15 @@ export function useNavigateHelper() { jumpToCollection, }), [ - jumpTo404, - jumpToExpired, - jumpToIndex, jumpToPage, + jumpToPageBlock, jumpToPublicWorkspacePage, - jumpToSignIn, jumpToSubPath, + jumpToIndex, + jumpTo404, openPage, + jumpToExpired, + jumpToSignIn, jumpToCollection, ] ); diff --git a/tests/affine-local/e2e/quick-search.spec.ts b/tests/affine-local/e2e/quick-search.spec.ts index 9d50e544d1..385c767239 100644 --- a/tests/affine-local/e2e/quick-search.spec.ts +++ b/tests/affine-local/e2e/quick-search.spec.ts @@ -6,6 +6,7 @@ import { getBlockSuiteEditorTitle, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; +import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; import { expect, type Page } from '@playwright/test'; const openQuickSearchByShortcut = async (page: Page) => { @@ -41,13 +42,45 @@ async function assertTitle(page: Page, text: string) { } } +async function checkElementIsInView(page: Page, searchText: string) { + const element = page.getByText(searchText); + // check if the element is in view + const elementRect = await element.boundingBox(); + const viewportHeight = page.viewportSize()?.height; + + if (!elementRect || !viewportHeight) { + return false; + } + expect(elementRect.y).toBeLessThan(viewportHeight); + expect(elementRect.y + elementRect.height).toBeGreaterThan(0); + + return true; +} + +async function waitForScrollToFinish(page: Page) { + await page.evaluate(async () => { + await new Promise(resolve => { + let lastScrollTop: number; + const interval = setInterval(() => { + const { scrollTop } = document.documentElement; + if (scrollTop != lastScrollTop) { + lastScrollTop = scrollTop; + } else { + clearInterval(interval); + resolve(null); + } + }, 500); // you can adjust the interval time + }); + }); +} + async function assertResultList(page: Page, texts: string[]) { const actual = await page .locator('[cmdk-item] [data-testid=cmdk-label]') .allInnerTexts(); const actualSplit = actual[0].split('\n'); expect(actualSplit[0]).toEqual(texts[0]); - expect(actualSplit[1]).toEqual(texts[0]); + expect(actualSplit[1]).toEqual(texts[1]); } async function titleIsFocused(page: Page) { @@ -133,7 +166,7 @@ test('Create a new page and search this page', async ({ page }) => { await openQuickSearchByShortcut(page); await page.keyboard.insertText('test123456'); await page.waitForTimeout(300); - await assertResultList(page, ['test123456']); + await assertResultList(page, ['test123456', 'test123456']); await page.keyboard.press('Enter'); await page.waitForTimeout(300); await assertTitle(page, 'test123456'); @@ -143,7 +176,7 @@ test('Create a new page and search this page', async ({ page }) => { await openQuickSearchByShortcut(page); await page.keyboard.insertText('test123456'); await page.waitForTimeout(300); - await assertResultList(page, ['test123456']); + await assertResultList(page, ['test123456', 'test123456']); await page.keyboard.press('Enter'); await page.waitForTimeout(300); await assertTitle(page, 'test123456'); @@ -338,3 +371,31 @@ test('show not found item', async ({ page }) => { await expect(notFoundItem).toBeVisible(); await expect(notFoundItem).toHaveText('Search for "test123456"'); }); + +test('can use cmdk to search page content and scroll to it', async ({ + page, +}) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + await getBlockSuiteEditorTitle(page).click(); + await getBlockSuiteEditorTitle(page).fill( + 'this is a new page to search for content' + ); + for (let i = 0; i < 50; i++) { + await page.keyboard.press('Enter'); + } + await page.keyboard.insertText('123456'); + await clickSideBarAllPageButton(page); + await openQuickSearchByShortcut(page); + await page.keyboard.insertText('123456'); + await page.waitForTimeout(300); + await assertResultList(page, [ + 'this is a new page to search for content', + '123456', + ]); + await page.keyboard.press('Enter'); + await waitForScrollToFinish(page); + const isVisitable = await checkElementIsInView(page, '123456'); + expect(isVisitable).toBe(true); +});