feat(core): add jump to block for cmdk (#4802)
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user