From 7d64815aca5d6734ce129fbd88c4f41e1779ae09 Mon Sep 17 00:00:00 2001 From: Qi <474021214@qq.com> Date: Thu, 13 Apr 2023 16:31:28 +0800 Subject: [PATCH] feat: add navigation path in quick search (#1920) --- .../affine/pinboard/pinboard-render/index.tsx | 43 +++-- .../src/components/affine/pinboard/styles.ts | 11 +- .../pure/quick-search-modal/index.tsx | 8 + .../navigation-path/index.tsx | 166 ++++++++++++++++++ .../navigation-path/styles.ts | 66 +++++++ .../navigation-path/utils.ts | 60 +++++++ .../pure/quick-search-modal/style.ts | 2 - apps/web/src/hooks/use-pinboard-data.ts | 13 +- .../component/src/ui/button/IconButton.tsx | 4 +- .../component/src/ui/tree-view/TreeNode.tsx | 2 + .../component/src/ui/tree-view/TreeView.tsx | 6 + packages/component/src/ui/tree-view/types.ts | 3 + packages/i18n/src/resources/en.json | 5 +- tests/libs/load-page.ts | 8 + tests/libs/page-logic.ts | 18 ++ tests/parallels/pin-board.spec.ts | 24 +-- tests/parallels/quick-search.spec.ts | 58 +++++- 17 files changed, 450 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/pure/quick-search-modal/navigation-path/index.tsx create mode 100644 apps/web/src/components/pure/quick-search-modal/navigation-path/styles.ts create mode 100644 apps/web/src/components/pure/quick-search-modal/navigation-path/utils.ts diff --git a/apps/web/src/components/affine/pinboard/pinboard-render/index.tsx b/apps/web/src/components/affine/pinboard/pinboard-render/index.tsx index ed582dd241..c2d0d8edb8 100644 --- a/apps/web/src/components/affine/pinboard/pinboard-render/index.tsx +++ b/apps/web/src/components/affine/pinboard/pinboard-render/index.tsx @@ -2,6 +2,7 @@ import { Input } from '@affine/component'; import { ArrowDownSmallIcon, EdgelessIcon, + LevelIcon, PageIcon, PivotsIcon, } from '@blocksuite/icons'; @@ -19,17 +20,25 @@ import { OperationButton } from './OperationButton'; const getIcon = (type: 'root' | 'edgeless' | 'page') => { switch (type) { case 'root': - return ; + return ; case 'edgeless': - return ; + return ; default: - return ; + return ; } }; export const PinboardRender: PinboardNode['render'] = ( node, - { isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected }, + { + isOver, + onAdd, + onDelete, + collapsed, + setCollapsed, + isSelected, + disableCollapse, + }, renderProps ) => { const { @@ -38,6 +47,7 @@ export const PinboardRender: PinboardNode['render'] = ( currentMeta, metas = [], blockSuiteWorkspace, + asPath, } = renderProps!; const record = useAtomValue(workspacePreferredModeAtom); const { setPageTitle } = usePageMetaHelper(blockSuiteWorkspace); @@ -60,17 +70,22 @@ export const PinboardRender: PinboardNode['render'] = ( onMouseLeave={() => setIsHover(false)} isOver={isOver || isSelected} active={active} + disableCollapse={!!disableCollapse} > - { - e.stopPropagation(); - setCollapsed(node.id, !collapsed); - }} - > - - + {!disableCollapse && ( + { + e.stopPropagation(); + setCollapsed(node.id, !collapsed); + }} + > + + + )} + + {asPath && !isRoot ? : null} {getIcon(isRoot ? 'root' : record[node.id])} {showRename ? ( diff --git a/apps/web/src/components/affine/pinboard/styles.ts b/apps/web/src/components/affine/pinboard/styles.ts index 90d3473b83..b8443a3838 100644 --- a/apps/web/src/components/affine/pinboard/styles.ts +++ b/apps/web/src/components/affine/pinboard/styles.ts @@ -32,13 +32,14 @@ export const StyledPinboard = styled('div')<{ disable?: boolean; active?: boolean; isOver?: boolean; -}>(({ disable = false, active = false, theme, isOver }) => { + disableCollapse?: boolean; +}>(({ disableCollapse, disable = false, active = false, theme, isOver }) => { return { width: '100%', height: '32px', borderRadius: '8px', ...displayFlex('flex-start', 'center'), - padding: '0 2px 0 16px', + padding: disableCollapse ? '0 5px' : '0 2px 0 16px', position: 'relative', color: disable ? theme.colors.disableColor @@ -54,7 +55,11 @@ export const StyledPinboard = styled('div')<{ textAlign: 'left', ...textEllipsis(1), }, - '> svg': { + '.path-icon': { + fontSize: '16px', + transform: 'translateY(-4px)', + }, + '.mode-icon': { fontSize: '20px', marginRight: '8px', flexShrink: '0', diff --git a/apps/web/src/components/pure/quick-search-modal/index.tsx b/apps/web/src/components/pure/quick-search-modal/index.tsx index 1ace2027ea..67165a1fcb 100644 --- a/apps/web/src/components/pure/quick-search-modal/index.tsx +++ b/apps/web/src/components/pure/quick-search-modal/index.tsx @@ -15,6 +15,7 @@ import { import type { BlockSuiteWorkspace } from '../../../shared'; import { Footer } from './Footer'; +import { NavigationPath } from './navigation-path'; import { PublishedResults } from './PublishedResults'; import { Results } from './Results'; import { SearchInput } from './SearchInput'; @@ -106,8 +107,15 @@ export const QuickSearchModal: React.FC = ({ maxHeight: '80vh', minHeight: isPublicAndNoQuery() ? '72px' : '412px', top: '80px', + overflow: 'hidden', }} > + { + setOpen(false); + }} + /> void; +}) => { + const metas = usePageMeta(blockSuiteWorkspace); + const router = useRouter(); + const { t } = useTranslation(); + + const [openExtend, setOpenExtend] = useState(false); + const pageId = propsPageId ?? router.query.pageId; + const { jumpToPage } = useRouterHelper(router); + const pathData = useMemo(() => { + const meta = metas.find(m => m.id === pageId); + const path = meta ? findPath(metas, meta) : []; + + const actualPath = calcHowManyPathShouldBeShown(path); + return { + hasEllipsis: path.length !== actualPath.length, + path: actualPath, + }; + }, [metas, pageId]); + + if (pathData.path.length < 2) { + // Means there is no parent page + return null; + } + return ( + <> + + {openExtend ? ( + {t('Navigation Path')} + ) : ( + pathData.path.map((meta, index) => { + const isLast = index === pathData.path.length - 1; + const showEllipsis = pathData.hasEllipsis && index === 1; + return ( + + {showEllipsis && ( + <> + setOpenExtend(true)} + > + + + + + )} + { + if (isLast) return; + jumpToPage(blockSuiteWorkspace.id, meta.id); + onJumpToPage?.(meta.id); + }} + title={meta.title} + > + {meta.title} + + {!isLast && } + + ); + }) + )} + + { + setOpenExtend(!openExtend); + }} + > + {openExtend ? : } + + + + + + ); +}; + +const NavigationPathExtendPanel = ({ + open, + metas, + blockSuiteWorkspace, + onJumpToPage, +}: { + open: boolean; + metas: PageMeta[]; + blockSuiteWorkspace: BlockSuiteWorkspace; + onJumpToPage?: (pageId: string) => void; +}) => { + const router = useRouter(); + const { jumpToPage } = useRouterHelper(router); + + const handlePinboardClick = useCallback( + (e: MouseEvent, node: PinboardNode) => { + jumpToPage(blockSuiteWorkspace.id, node.id); + onJumpToPage?.(node.id); + }, + [blockSuiteWorkspace.id, jumpToPage, onJumpToPage] + ); + + const { data } = usePinboardData({ + metas, + pinboardRender: PinboardRender, + blockSuiteWorkspace: blockSuiteWorkspace, + onClick: handlePinboardClick, + asPath: true, + }); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/web/src/components/pure/quick-search-modal/navigation-path/styles.ts b/apps/web/src/components/pure/quick-search-modal/navigation-path/styles.ts new file mode 100644 index 0000000000..d6b70dd248 --- /dev/null +++ b/apps/web/src/components/pure/quick-search-modal/navigation-path/styles.ts @@ -0,0 +1,66 @@ +import { displayFlex, styled, textEllipsis } from '@affine/component'; + +export const StyledNavigationPathContainer = styled('div')(({ theme }) => { + return { + height: '46px', + ...displayFlex('flex-start', 'center'), + background: theme.colors.hubBackground, + padding: '0 40px 0 20px', + position: 'relative', + fontSize: theme.font.sm, + zIndex: 2, + '.collapse-btn': { + position: 'absolute', + right: '12px', + top: 0, + bottom: 0, + margin: 'auto', + }, + '.path-arrow': { + fontSize: '16px', + color: theme.colors.iconColor, + }, + }; +}); + +export const StyledNavPathLink = styled('div')<{ active?: boolean }>( + ({ theme, active }) => { + return { + color: active ? theme.colors.textColor : theme.colors.secondaryTextColor, + cursor: active ? 'auto' : 'pointer', + maxWidth: '160px', + ...textEllipsis(1), + padding: '0 4px', + transition: 'background .15s', + ':hover': active + ? {} + : { + background: theme.colors.hoverBackground, + borderRadius: '4px', + }, + }; + } +); + +export const StyledNavPathExtendContainer = styled('div')<{ show: boolean }>( + ({ show, theme }) => { + return { + position: 'absolute', + left: '0', + top: show ? '0' : '-100%', + zIndex: '1', + height: '100%', + width: '100%', + background: theme.colors.hubBackground, + transition: 'top .15s', + fontSize: theme.font.sm, + color: theme.colors.secondaryTextColor, + paddingTop: '46px', + paddingRight: '12px', + + '.tree-container': { + padding: '0 12px 0 15px', + }, + }; + } +); diff --git a/apps/web/src/components/pure/quick-search-modal/navigation-path/utils.ts b/apps/web/src/components/pure/quick-search-modal/navigation-path/utils.ts new file mode 100644 index 0000000000..58b9c06fc6 --- /dev/null +++ b/apps/web/src/components/pure/quick-search-modal/navigation-path/utils.ts @@ -0,0 +1,60 @@ +import type { PageMeta } from '@blocksuite/store'; + +export function findPath(metas: PageMeta[], meta: PageMeta): PageMeta[] { + function helper(group: PageMeta[]): PageMeta[] { + const last = group[group.length - 1]; + const parent = metas.find(m => m.subpageIds.includes(last.id)); + if (parent) { + return helper([...group, parent]); + } + return group; + } + + return helper([meta]).reverse(); +} + +function getPathItemWidth(content: string) { + // padding is 8px, arrow is 16px, and each char is 10px + // the max width is 160px + const charWidth = 10; + const w = content.length * charWidth + 8 + 16; + return w > 160 ? 160 : w; +} + +// XXX: this is a static way to calculate the path width, not get the real width +export function calcHowManyPathShouldBeShown(path: PageMeta[]): PageMeta[] { + if (path.length === 0) { + return []; + } + const first = path[0]; + const last = path[path.length - 1]; + // 20 is the ellipsis icon width + const maxWidth = 550 - 20; + if (first.id === last.id) { + return [first]; + } + + function getMiddlePath(restWidth: number, restPath: PageMeta[]): PageMeta[] { + if (restPath.length === 0) { + return []; + } + const last = restPath[restPath.length - 1]; + const w = getPathItemWidth(last.title); + if (restWidth - w > 80) { + return [ + ...getMiddlePath(restWidth - w, restPath.slice(0, restPath.length - 1)), + last, + ]; + } + return []; + } + + return [ + first, + ...getMiddlePath( + maxWidth - getPathItemWidth(first.title), + path.slice(1, -1) + ), + last, + ]; +} diff --git a/apps/web/src/components/pure/quick-search-modal/style.ts b/apps/web/src/components/pure/quick-search-modal/style.ts index fe2e2ea28f..249db812e1 100644 --- a/apps/web/src/components/pure/quick-search-modal/style.ts +++ b/apps/web/src/components/pure/quick-search-modal/style.ts @@ -114,9 +114,7 @@ export const StyledModalDivider = styled('div')(({ theme }) => { width: 'auto', height: '0', margin: '6px 16px', - position: 'relative', borderTop: `0.5px solid ${theme.colors.borderColor}`, - transition: 'all 0.15s', }; }); diff --git a/apps/web/src/hooks/use-pinboard-data.ts b/apps/web/src/hooks/use-pinboard-data.ts index c7e4945f9f..6aadc2eefd 100644 --- a/apps/web/src/hooks/use-pinboard-data.ts +++ b/apps/web/src/hooks/use-pinboard-data.ts @@ -9,6 +9,8 @@ export type RenderProps = { blockSuiteWorkspace: BlockSuiteWorkspace; onClick?: (e: MouseEvent, node: PinboardNode) => void; showOperationButton?: boolean; + // If true, the node will be rendered with path icon at start + asPath?: boolean; }; export type NodeRenderProps = RenderProps & { @@ -57,6 +59,7 @@ export function usePinboardData({ blockSuiteWorkspace, onClick, showOperationButton, + asPath, }: { metas: PageMeta[]; pinboardRender: PinboardNode['render']; @@ -67,8 +70,16 @@ export function usePinboardData({ blockSuiteWorkspace, onClick, showOperationButton, + asPath, }), - [blockSuiteWorkspace, metas, onClick, pinboardRender, showOperationButton] + [ + asPath, + blockSuiteWorkspace, + metas, + onClick, + pinboardRender, + showOperationButton, + ] ); return { diff --git a/packages/component/src/ui/button/IconButton.tsx b/packages/component/src/ui/button/IconButton.tsx index 7953108571..0a14d3ea24 100644 --- a/packages/component/src/ui/button/IconButton.tsx +++ b/packages/component/src/ui/button/IconButton.tsx @@ -12,8 +12,8 @@ const SIZE_CONFIG = { areaSize: 20, }, [SIZE_MIDDLE]: { - iconSize: 20, - areaSize: 28, + iconSize: 16, + areaSize: 24, }, [SIZE_NORMAL]: { iconSize: 24, diff --git a/packages/component/src/ui/tree-view/TreeNode.tsx b/packages/component/src/ui/tree-view/TreeNode.tsx index 496ae8f7cf..0c945ab165 100644 --- a/packages/component/src/ui/tree-view/TreeNode.tsx +++ b/packages/component/src/ui/tree-view/TreeNode.tsx @@ -105,6 +105,7 @@ const TreeNodeItem = ({ onAdd, onDelete, dropRef, + disableCollapse, }: TreeNodeItemProps) => { return (
@@ -115,6 +116,7 @@ const TreeNodeItem = ({ collapsed, setCollapsed, isSelected: selectedId === node.id, + disableCollapse, })}
); diff --git a/packages/component/src/ui/tree-view/TreeView.tsx b/packages/component/src/ui/tree-view/TreeView.tsx index 61c87c780e..3db4bc4254 100644 --- a/packages/component/src/ui/tree-view/TreeView.tsx +++ b/packages/component/src/ui/tree-view/TreeView.tsx @@ -12,6 +12,7 @@ export const TreeView = ({ onSelect, enableDnd = true, initialCollapsedIds = [], + disableCollapse, ...otherProps }: TreeViewProps) => { const [selectedId, setSelectedId] = useState(); @@ -62,6 +63,9 @@ export const TreeView = ({ }, [data, selectedId]); const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => { + if (disableCollapse) { + return; + } if (collapsed) { setCollapsedIds(ids => [...ids, id]); } else { @@ -81,6 +85,7 @@ export const TreeView = ({ node={node} selectedId={selectedId} enableDnd={enableDnd} + disableCollapse={disableCollapse} {...otherProps} /> ))} @@ -99,6 +104,7 @@ export const TreeView = ({ node={node} selectedId={selectedId} enableDnd={enableDnd} + disableCollapse={disableCollapse} {...otherProps} /> ))} diff --git a/packages/component/src/ui/tree-view/types.ts b/packages/component/src/ui/tree-view/types.ts index 1b2be07962..4801ecb39c 100644 --- a/packages/component/src/ui/tree-view/types.ts +++ b/packages/component/src/ui/tree-view/types.ts @@ -23,6 +23,7 @@ export type Node = { collapsed: boolean; setCollapsed: (id: string, collapsed: boolean) => void; isSelected: boolean; + disableCollapse?: ReactNode; }, renderProps?: RenderProps ) => ReactNode; @@ -39,6 +40,7 @@ type CommonProps = { onDrop?: OnDrop; // Only trigger when the enableKeyboardSelection is true onSelect?: (id: string) => void; + disableCollapse?: ReactNode; }; export type TreeNodeProps = { @@ -65,6 +67,7 @@ export type TreeNodeItemProps = { export type TreeViewProps = { data: Node[]; initialCollapsedIds?: string[]; + disableCollapse?: boolean; } & CommonProps; export type NodeLIneProps = { diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index 31b0fd7811..a63ccb5726 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -201,5 +201,8 @@ "Move page to...": "Move page to...", "Remove from Pivots": "Remove from Pivots", "RFP": "Pages can be freely added/removed from pivots, remaining accessible from \"All Pages\".", - "Discover what's new!": "Discover what's new!" + "Discover what's new!": "Discover what's new!", + "Navigation Path": "Navigation Path", + "View Navigation Path": "View Navigation Path", + "Back to Quick Search": "Back to Quick Search" } diff --git a/tests/libs/load-page.ts b/tests/libs/load-page.ts index b999d80156..cd066c33c4 100644 --- a/tests/libs/load-page.ts +++ b/tests/libs/load-page.ts @@ -1,6 +1,14 @@ import type { Page } from '@playwright/test'; +import { getMetas } from './utils'; + export async function openHomePage(page: Page) { await page.goto('http://localhost:8080'); await page.waitForSelector('#__next'); } + +export async function initHomePageWithPinboard(page: Page) { + await openHomePage(page); + await page.waitForSelector('[data-testid="sidebar-pinboard-container"]'); + return (await getMetas(page)).find(m => m.isRootPinboard); +} diff --git a/tests/libs/page-logic.ts b/tests/libs/page-logic.ts index 902151ad59..877e65169f 100644 --- a/tests/libs/page-logic.ts +++ b/tests/libs/page-logic.ts @@ -27,3 +27,21 @@ export async function clickPageMoreActions(page: Page) { .getByTestId('editor-option-menu') .click(); } + +export async function createPinboardPage( + page: Page, + parentId: string, + title: string +) { + await newPage(page); + await page.focus('.affine-default-page-block-title'); + await page.type('.affine-default-page-block-title', title, { + delay: 100, + }); + await clickPageMoreActions(page); + await page.getByTestId('move-to-menu-item').click(); + await page + .getByTestId('pinboard-menu') + .getByTestId(`pinboard-${parentId}`) + .click(); +} diff --git a/tests/parallels/pin-board.spec.ts b/tests/parallels/pin-board.spec.ts index 6558f326e8..0dc2c5f12a 100644 --- a/tests/parallels/pin-board.spec.ts +++ b/tests/parallels/pin-board.spec.ts @@ -1,31 +1,11 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -import { openHomePage } from '../libs/load-page'; -import { clickPageMoreActions, newPage } from '../libs/page-logic'; +import { initHomePageWithPinboard } from '../libs/load-page'; +import { createPinboardPage } from '../libs/page-logic'; import { test } from '../libs/playwright'; import { getMetas } from '../libs/utils'; -async function createPinboardPage(page: Page, parentId: string, title: string) { - await newPage(page); - await page.focus('.affine-default-page-block-title'); - await page.type('.affine-default-page-block-title', title, { - delay: 100, - }); - await clickPageMoreActions(page); - await page.getByTestId('move-to-menu-item').click(); - await page - .getByTestId('pinboard-menu') - .getByTestId(`pinboard-${parentId}`) - .click(); -} - -async function initHomePageWithPinboard(page: Page) { - await openHomePage(page); - await page.waitForSelector('[data-testid="sidebar-pinboard-container"]'); - return (await getMetas(page)).find(m => m.isRootPinboard); -} - async function openPinboardPageOperationMenu(page: Page, id: string) { const node = await page .getByTestId('sidebar-pinboard-container') diff --git a/tests/parallels/quick-search.spec.ts b/tests/parallels/quick-search.spec.ts index 41a77ccfa8..c3881d093e 100644 --- a/tests/parallels/quick-search.spec.ts +++ b/tests/parallels/quick-search.spec.ts @@ -1,8 +1,12 @@ import { expect, type Page } from '@playwright/test'; import { withCtrlOrMeta } from '../libs/keyboard'; -import { openHomePage } from '../libs/load-page'; -import { newPage, waitMarkdownImported } from '../libs/page-logic'; +import { initHomePageWithPinboard, openHomePage } from '../libs/load-page'; +import { + createPinboardPage, + newPage, + waitMarkdownImported, +} from '../libs/page-logic'; import { test } from '../libs/playwright'; const openQuickSearchByShortcut = async (page: Page) => @@ -204,4 +208,54 @@ test.describe('Novice guidance for quick search', () => { await page.getByTestId('sliderBar-arrowButton-collapse').click(); await expect(quickSearchTips).not.toBeVisible(); }); + + test('Show navigation path if page is a subpage', async ({ page }) => { + const rootPinboardMeta = await initHomePageWithPinboard(page); + await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1'); + await openQuickSearchByShortcut(page); + expect(await page.getByTestId('navigation-path').count()).toBe(1); + }); + test('Not show navigation path if page is not a subpage or current page is not in editor', async ({ + page, + }) => { + await openHomePage(page); + await waitMarkdownImported(page); + await openQuickSearchByShortcut(page); + expect(await page.getByTestId('navigation-path').count()).toBe(0); + }); + test('Navigation path item click will jump to page, but not current active item', async ({ + page, + }) => { + const rootPinboardMeta = await initHomePageWithPinboard(page); + await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1'); + await openQuickSearchByShortcut(page); + const oldUrl = page.url(); + expect( + await page.locator('[data-testid="navigation-path-link"]').count() + ).toBe(2); + await page.locator('[data-testid="navigation-path-link"]').nth(1).click(); + expect(page.url()).toBe(oldUrl); + await page.locator('[data-testid="navigation-path-link"]').nth(0).click(); + expect(page.url()).not.toBe(oldUrl); + }); + test('Navigation path expand', async ({ page }) => { + // + const rootPinboardMeta = await initHomePageWithPinboard(page); + await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1'); + await openQuickSearchByShortcut(page); + const top = await page + .getByTestId('navigation-path-expand-panel') + .evaluate(el => { + return window.getComputedStyle(el).getPropertyValue('top'); + }); + expect(parseInt(top)).toBeLessThan(0); + await page.getByTestId('navigation-path-expand-btn').click(); + await page.waitForTimeout(500); + const expandTop = await page + .getByTestId('navigation-path-expand-panel') + .evaluate(el => { + return window.getComputedStyle(el).getPropertyValue('top'); + }); + expect(expandTop).toBe('0px'); + }); });