mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-13 16:16:46 +08:00
feat: fav page references (#2422)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
@@ -1,40 +1,103 @@
|
||||
import { MenuLinkItem } from '@affine/component/app-sidebar';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { workspacePreferredModeAtom } from '../../../../atoms';
|
||||
import type { FavoriteListProps } from '../index';
|
||||
import EmptyItem from './empty-item';
|
||||
export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
|
||||
|
||||
interface FavoriteMenuItemProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
metaMapping: Record<string, PageMeta>;
|
||||
parentIds: Set<string>;
|
||||
}
|
||||
|
||||
function FavoriteMenuItem({
|
||||
workspace,
|
||||
pageId,
|
||||
metaMapping,
|
||||
parentIds,
|
||||
}: FavoriteMenuItemProps) {
|
||||
const router = useRouter();
|
||||
const record = useAtomValue(workspacePreferredModeAtom);
|
||||
const pageMeta = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const workspaceId = currentWorkspace.id;
|
||||
const active = router.query.pageId === pageId;
|
||||
const icon = record[pageId] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
const references = useBlockSuitePageReferences(workspace, pageId);
|
||||
const referencesToShow = useMemo(() => {
|
||||
return [...new Set(references.filter(ref => !parentIds.has(ref)))];
|
||||
}, [references, parentIds]);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const collapsible = referencesToShow.length > 0 && parentIds.size === 0;
|
||||
const showReferences = collapsible ? !collapsed : referencesToShow.length > 0;
|
||||
const nestedItem = parentIds.size > 0;
|
||||
return (
|
||||
<>
|
||||
<MenuLinkItem
|
||||
style={{
|
||||
marginLeft: nestedItem ? '12px' : undefined,
|
||||
width: nestedItem ? 'calc(100% - 12px)' : undefined,
|
||||
}}
|
||||
data-type="favorite-list-item"
|
||||
data-testid={`favorite-list-item-${pageId}`}
|
||||
active={active}
|
||||
href={`/workspace/${workspace.id}/${pageId}`}
|
||||
icon={icon}
|
||||
collapsed={collapsible ? collapsed : undefined}
|
||||
onCollapsedChange={setCollapsed}
|
||||
>
|
||||
<span>{metaMapping[pageId]?.title || 'Untitled'}</span>
|
||||
</MenuLinkItem>
|
||||
{showReferences &&
|
||||
referencesToShow.map(ref => {
|
||||
return (
|
||||
<FavoriteMenuItem
|
||||
key={ref}
|
||||
workspace={workspace}
|
||||
pageId={ref}
|
||||
metaMapping={metaMapping}
|
||||
parentIds={new Set([...parentIds, pageId])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
|
||||
const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
|
||||
const favoriteList = useMemo(
|
||||
() => pageMeta.filter(p => p.favorite && !p.trash),
|
||||
[pageMeta]
|
||||
() => metas.filter(p => p.favorite && !p.trash),
|
||||
[metas]
|
||||
);
|
||||
|
||||
const metaMapping = useMemo(
|
||||
() =>
|
||||
metas.reduce((acc, meta) => {
|
||||
acc[meta.id] = meta;
|
||||
return acc;
|
||||
}, {} as Record<string, PageMeta>),
|
||||
[metas]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{favoriteList.map((pageMeta, index) => {
|
||||
const active = router.query.pageId === pageMeta.id;
|
||||
const icon =
|
||||
record[pageMeta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
return (
|
||||
<MenuLinkItem
|
||||
<FavoriteMenuItem
|
||||
key={`${pageMeta}-${index}`}
|
||||
data-testid={`favorite-list-item-${pageMeta.id}`}
|
||||
active={active}
|
||||
href={`/workspace/${workspaceId}/${pageMeta.id}`}
|
||||
icon={icon}
|
||||
>
|
||||
<span>{pageMeta.title || 'Untitled'}</span>
|
||||
</MenuLinkItem>
|
||||
metaMapping={metaMapping}
|
||||
pageId={pageMeta.id}
|
||||
// memo?
|
||||
parentIds={new Set()}
|
||||
workspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{favoriteList.length === 0 && <EmptyItem />}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@blocksuite/icons';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { type PropsWithChildren, useState } from 'react';
|
||||
|
||||
import { AppSidebar, AppSidebarFallback, appSidebarOpenAtom } from '.';
|
||||
import { AddPageButton } from './add-page-button';
|
||||
@@ -79,6 +79,7 @@ export const Fallback = () => {
|
||||
};
|
||||
|
||||
export const WithItems: StoryFn = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<Container>
|
||||
<AppSidebar>
|
||||
@@ -111,11 +112,22 @@ export const WithItems: StoryFn = () => {
|
||||
<SidebarScrollableContainer>
|
||||
<CategoryDivider label="Favorites" />
|
||||
<MenuLinkItem
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
icon={<SettingsIcon />}
|
||||
href="/test"
|
||||
onClick={() => alert('opened')}
|
||||
>
|
||||
Settings
|
||||
Collapsible Item
|
||||
</MenuLinkItem>
|
||||
<MenuLinkItem
|
||||
collapsed={!collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
icon={<SettingsIcon />}
|
||||
href="/test"
|
||||
onClick={() => alert('opened')}
|
||||
>
|
||||
Collapsible Item
|
||||
</MenuLinkItem>
|
||||
<MenuLinkItem
|
||||
icon={<SettingsIcon />}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const root = style({
|
||||
minHeight: '30px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 12px',
|
||||
padding: '0 8px 0 12px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
@@ -27,6 +27,11 @@ export const root = style({
|
||||
// make this a variable?
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04);',
|
||||
},
|
||||
'&[data-collapsible="true"]': {
|
||||
width: 'calc(100% + 8px)',
|
||||
transform: 'translateX(-8px)',
|
||||
paddingLeft: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,11 +42,49 @@ export const content = style({
|
||||
});
|
||||
|
||||
export const icon = style({
|
||||
marginRight: '14px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
fontSize: '20px',
|
||||
});
|
||||
|
||||
export const collapsedIconContainer = style({
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'transform 0.2s',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const iconsContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: '28px',
|
||||
selectors: {
|
||||
'&[data-collapsible="true"]': {
|
||||
width: '40px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsIcon } from '@blocksuite/icons';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { MenuItem, MenuLinkItem } from '.';
|
||||
|
||||
@@ -9,6 +10,7 @@ export default {
|
||||
} satisfies Meta;
|
||||
|
||||
export const Default: StoryFn = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<main style={{ width: '240px' }}>
|
||||
<MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}>
|
||||
@@ -29,6 +31,14 @@ export const Default: StoryFn = () => {
|
||||
>
|
||||
Primary Item
|
||||
</MenuLinkItem>
|
||||
<MenuItem
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => alert('opened')}
|
||||
>
|
||||
Collapsible Item
|
||||
</MenuItem>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
@@ -9,6 +10,8 @@ interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactElement;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
collapsed?: boolean; // true, false, undefined. undefined means no collapse
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
interface MenuLinkItemProps extends MenuItemProps, Pick<LinkProps, 'href'> {}
|
||||
@@ -19,8 +22,14 @@ export function MenuItem({
|
||||
active,
|
||||
children,
|
||||
disabled,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
...props
|
||||
}: MenuItemProps) {
|
||||
const collapsible = collapsed !== undefined;
|
||||
if (collapsible && !onCollapsedChange) {
|
||||
throw new Error('onCollapsedChange is required when collapsed is defined');
|
||||
}
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
@@ -28,11 +37,31 @@ export function MenuItem({
|
||||
onClick={onClick}
|
||||
data-active={active}
|
||||
data-disabled={disabled}
|
||||
data-collapsible={collapsible}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx([styles.icon, icon.props.className]),
|
||||
})}
|
||||
<div className={styles.iconsContainer} data-collapsible={collapsible}>
|
||||
{collapsible && (
|
||||
<div
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // for links
|
||||
onCollapsedChange?.(!collapsed);
|
||||
}}
|
||||
data-testid="fav-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ArrowDownSmallIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx([styles.icon, icon.props.className]),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const root = style({
|
||||
});
|
||||
|
||||
export const icon = style({
|
||||
marginRight: '14px',
|
||||
marginRight: '8px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
fontSize: '20px',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
|
||||
import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page';
|
||||
|
||||
function getPageReferences(page: Page): string[] {
|
||||
// todo: is there a way to use page indexer to get all references?
|
||||
return page
|
||||
.getBlockByFlavour('affine:paragraph')
|
||||
.flatMap(b => b.text?.toDelta())
|
||||
.map(v => v?.attributes?.reference?.pageId)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const pageReferencesAtomFamily = atomFamily((page: Page | null) => {
|
||||
if (page === null) {
|
||||
return atom([]);
|
||||
}
|
||||
const baseAtom = atom<string[]>(getPageReferences(page));
|
||||
baseAtom.onMount = set => {
|
||||
const dispose = page.slots.yUpdated.on(() => {
|
||||
set(getPageReferences(page));
|
||||
});
|
||||
return () => {
|
||||
dispose.dispose();
|
||||
};
|
||||
};
|
||||
return baseAtom;
|
||||
});
|
||||
|
||||
export function useBlockSuitePageReferences(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string
|
||||
): string[] {
|
||||
const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId);
|
||||
return useAtomValue(pageReferencesAtomFamily(page));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export async function waitMarkdownImported(page: Page) {
|
||||
await page.waitForSelector('v-line');
|
||||
@@ -16,6 +17,30 @@ export function getBlockSuiteEditorTitle(page: Page) {
|
||||
return page.locator('v-line').nth(0);
|
||||
}
|
||||
|
||||
export async function type(page: Page, content: string, delay = 50) {
|
||||
await page.keyboard.type(content, { delay });
|
||||
}
|
||||
|
||||
export async function pressEnter(page: Page) {
|
||||
// avoid flaky test by simulate real user input
|
||||
await page.keyboard.press('Enter', { delay: 50 });
|
||||
}
|
||||
|
||||
export const createLinkedPage = async (page: Page, pageName?: string) => {
|
||||
await page.keyboard.type('@', { delay: 50 });
|
||||
const linkedPagePopover = page.locator('.linked-page-popover');
|
||||
await expect(linkedPagePopover).toBeVisible();
|
||||
if (pageName) {
|
||||
await type(page, pageName);
|
||||
} else {
|
||||
pageName = 'Untitled';
|
||||
}
|
||||
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('Enter', { delay: 50 });
|
||||
};
|
||||
|
||||
export async function clickPageMoreActions(page: Page) {
|
||||
return page
|
||||
.getByTestId('editor-header-items')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect } from '@playwright/test';
|
||||
import { openHomePage } from '../libs/load-page';
|
||||
import {
|
||||
clickPageMoreActions,
|
||||
createLinkedPage,
|
||||
getBlockSuiteEditorTitle,
|
||||
newPage,
|
||||
waitMarkdownImported,
|
||||
@@ -34,28 +35,39 @@ test('Show favorite items in sidebar', async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Show favorite items in favorite list', async ({ page }) => {
|
||||
test('Show favorite reference in sidebar', async ({ page }) => {
|
||||
await openHomePage(page);
|
||||
await waitMarkdownImported(page);
|
||||
await newPage(page);
|
||||
await getBlockSuiteEditorTitle(page).click();
|
||||
await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite');
|
||||
await page.getByTestId('all-pages').click();
|
||||
const cell = page.getByRole('cell', {
|
||||
name: 'this is a new page to favorite',
|
||||
});
|
||||
expect(cell).not.toBeUndefined();
|
||||
await cell.click();
|
||||
|
||||
// goes to main content
|
||||
await page.keyboard.press('Enter', { delay: 50 });
|
||||
|
||||
await createLinkedPage(page, 'Another page');
|
||||
|
||||
const newPageId = page.url().split('/').reverse()[0];
|
||||
|
||||
await clickPageMoreActions(page);
|
||||
|
||||
const favoriteBtn = page.getByTestId('editor-option-menu-favorite');
|
||||
await favoriteBtn.click();
|
||||
|
||||
await page.getByTestId('all-pages').click();
|
||||
const favItemTestId = 'favorite-list-item-' + newPageId;
|
||||
|
||||
expect(
|
||||
page.getByRole('cell', { name: 'this is a new page to favorite' })
|
||||
).not.toBeUndefined();
|
||||
const favoriteListItemInSidebar = page.getByTestId(favItemTestId);
|
||||
expect(await favoriteListItemInSidebar.textContent()).toBe(
|
||||
'this is a new page to favorite'
|
||||
);
|
||||
|
||||
await page.getByRole('cell').getByRole('button').nth(0).click();
|
||||
const collapseButton = favoriteListItemInSidebar.locator(
|
||||
'[data-testid="fav-collapsed-button"]'
|
||||
);
|
||||
|
||||
await expect(collapseButton).toBeVisible();
|
||||
await collapseButton.click();
|
||||
await expect(
|
||||
page.locator('[data-type="favorite-list-item"] >> text=Another page')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user