feat(core): add jump to block for cmdk (#4802)

This commit is contained in:
JimmFly
2023-11-02 19:49:49 +08:00
committed by GitHub
parent 7068d5f38a
commit 0a88be7771
4 changed files with 145 additions and 15 deletions

View File

@@ -25,8 +25,10 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from 'react'; } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useLocation } from 'react-router-dom';
import { pageSettingFamily } from '../atoms'; import { pageSettingFamily } from '../atoms';
import { fontStyleOptions } from '../atoms/settings'; 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 { editorContainer, pluginContainer } from './page-detail-editor.css';
import { TrashButtonGroup } from './pure/trash-button-group'; import { TrashButtonGroup } from './pure/trash-button-group';
function useRouterHash() {
return useLocation().hash.substring(1);
}
export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void; export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void;
export interface PageDetailEditorProps { export interface PageDetailEditorProps {
@@ -65,8 +71,10 @@ const EditorWrapper = memo(function EditorWrapper({
const meta = useBlockSuitePageMeta(workspace).find( const meta = useBlockSuitePageMeta(workspace).find(
meta => meta.id === pageId meta => meta.id === pageId
); );
const { switchToEdgelessMode, switchToPageMode } = const { switchToEdgelessMode, switchToPageMode } =
useBlockSuiteMetaHelper(workspace); useBlockSuiteMetaHelper(workspace);
const pageSettingAtom = pageSettingFamily(pageId); const pageSettingAtom = pageSettingFamily(pageId);
const pageSetting = useAtomValue(pageSettingAtom); const pageSetting = useAtomValue(pageSettingAtom);
const currentMode = pageSetting?.mode ?? 'page'; const currentMode = pageSetting?.mode ?? 'page';
@@ -83,6 +91,29 @@ const EditorWrapper = memo(function EditorWrapper({
return fontStyle.value; return fontStyle.value;
}, [appSettings.fontStyle]); }, [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( const setEditorMode = useCallback(
(mode: 'page' | 'edgeless') => { (mode: 'page' | 'edgeless') => {
if (mode === 'edgeless') { if (mode === 'edgeless') {
@@ -153,6 +184,7 @@ const EditorWrapper = memo(function EditorWrapper({
window.setTimeout(() => { window.setTimeout(() => {
disposes.forEach(dispose => dispose()); disposes.forEach(dispose => dispose());
}); });
setLoading(false);
}; };
}, },
[onLoad] [onLoad]

View File

@@ -39,6 +39,11 @@ import { WorkspaceSubPath } from '../../../shared';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import type { CMDKCommand, CommandContext } from './types'; import type { CMDKCommand, CommandContext } from './types';
interface SearchResultsValue {
space: string;
content: string;
}
export const cmdkQueryAtom = atom(''); export const cmdkQueryAtom = atom('');
export const cmdkValueAtom = atom(''); export const cmdkValueAtom = atom('');
@@ -153,7 +158,8 @@ export const pageToCommand = (
label?: { label?: {
title: string; title: string;
subTitle?: string; subTitle?: string;
} },
blockId?: string
): CMDKCommand => { ): CMDKCommand => {
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode; const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
const currentWorkspaceId = store.get(currentWorkspaceIdAtom); const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
@@ -177,7 +183,14 @@ export const pageToCommand = (
console.error('current workspace not found'); console.error('current workspace not found');
return; return;
} }
navigationHelper.jumpToPage(currentWorkspaceId, page.id); if (blockId) {
return navigationHelper.jumpToPageBlock(
currentWorkspaceId,
page.id,
blockId
);
}
return navigationHelper.jumpToPage(currentWorkspaceId, page.id);
}, },
icon: pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />, icon: pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />,
timestamp: page.updatedDate, timestamp: page.updatedDate,
@@ -205,17 +218,23 @@ export const usePageCommands = () => {
}); });
} else { } else {
// queried pages that has matched contents // queried pages that has matched contents
const searchResults = Array.from( // TODO: we shall have a debounce for global search here
workspace.blockSuiteWorkspace.search({ query }).values() const searchResults = workspace.blockSuiteWorkspace.search({
) as unknown as { space: string; content: string }[]; query,
}) as unknown as Map<string, SearchResultsValue>;
const resultValues = Array.from(searchResults.values());
const pageIds = searchResults.map(result => { const pageIds = resultValues.map(result => {
if (result.space.startsWith('space:')) { if (result.space.startsWith('space:')) {
return result.space.slice(6); return result.space.slice(6);
} else { } else {
return result.space; return result.space;
} }
}); });
const reverseMapping: Map<string, string> = new Map();
searchResults.forEach((value, key) => {
reverseMapping.set(value.space, key);
});
results = pages.map(page => { results = pages.map(page => {
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode; const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
@@ -225,17 +244,20 @@ export const usePageCommands = () => {
const label = { const label = {
title: page.title || t['Untitled'](), // Used to ensure that a title exists title: page.title || t['Untitled'](), // Used to ensure that a title exists
subTitle: 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( const command = pageToCommand(
category, category,
page, page,
store, store,
navigationHelper, navigationHelper,
t, t,
label label,
blockId
); );
if (pageIds.includes(page.id)) { if (pageIds.includes(page.id)) {

View File

@@ -29,6 +29,19 @@ export function useNavigateHelper() {
}, },
[navigate] [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( const jumpToCollection = useCallback(
( (
workspaceId: string, workspaceId: string,
@@ -122,6 +135,7 @@ export function useNavigateHelper() {
return useMemo( return useMemo(
() => ({ () => ({
jumpToPage, jumpToPage,
jumpToPageBlock,
jumpToPublicWorkspacePage, jumpToPublicWorkspacePage,
jumpToSubPath, jumpToSubPath,
jumpToIndex, jumpToIndex,
@@ -132,14 +146,15 @@ export function useNavigateHelper() {
jumpToCollection, jumpToCollection,
}), }),
[ [
jumpTo404,
jumpToExpired,
jumpToIndex,
jumpToPage, jumpToPage,
jumpToPageBlock,
jumpToPublicWorkspacePage, jumpToPublicWorkspacePage,
jumpToSignIn,
jumpToSubPath, jumpToSubPath,
jumpToIndex,
jumpTo404,
openPage, openPage,
jumpToExpired,
jumpToSignIn,
jumpToCollection, jumpToCollection,
] ]
); );

View File

@@ -6,6 +6,7 @@ import {
getBlockSuiteEditorTitle, getBlockSuiteEditorTitle,
waitForEditorLoad, waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
const openQuickSearchByShortcut = async (page: Page) => { 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[]) { async function assertResultList(page: Page, texts: string[]) {
const actual = await page const actual = await page
.locator('[cmdk-item] [data-testid=cmdk-label]') .locator('[cmdk-item] [data-testid=cmdk-label]')
.allInnerTexts(); .allInnerTexts();
const actualSplit = actual[0].split('\n'); const actualSplit = actual[0].split('\n');
expect(actualSplit[0]).toEqual(texts[0]); expect(actualSplit[0]).toEqual(texts[0]);
expect(actualSplit[1]).toEqual(texts[0]); expect(actualSplit[1]).toEqual(texts[1]);
} }
async function titleIsFocused(page: Page) { async function titleIsFocused(page: Page) {
@@ -133,7 +166,7 @@ test('Create a new page and search this page', async ({ page }) => {
await openQuickSearchByShortcut(page); await openQuickSearchByShortcut(page);
await page.keyboard.insertText('test123456'); await page.keyboard.insertText('test123456');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await assertResultList(page, ['test123456']); await assertResultList(page, ['test123456', 'test123456']);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await assertTitle(page, 'test123456'); await assertTitle(page, 'test123456');
@@ -143,7 +176,7 @@ test('Create a new page and search this page', async ({ page }) => {
await openQuickSearchByShortcut(page); await openQuickSearchByShortcut(page);
await page.keyboard.insertText('test123456'); await page.keyboard.insertText('test123456');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await assertResultList(page, ['test123456']); await assertResultList(page, ['test123456', 'test123456']);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await assertTitle(page, 'test123456'); await assertTitle(page, 'test123456');
@@ -338,3 +371,31 @@ test('show not found item', async ({ page }) => {
await expect(notFoundItem).toBeVisible(); await expect(notFoundItem).toBeVisible();
await expect(notFoundItem).toHaveText('Search for "test123456"'); 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);
});