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);
+});