feat: add root pinboard & rename pivots to pinboard (#1843)
This commit is contained in:
@@ -19,8 +19,8 @@ import {
|
|||||||
import { useWorkspacesHelper } from '../../hooks/use-workspaces';
|
import { useWorkspacesHelper } from '../../hooks/use-workspaces';
|
||||||
import { ThemeProvider } from '../../providers/ThemeProvider';
|
import { ThemeProvider } from '../../providers/ThemeProvider';
|
||||||
import type { BlockSuiteWorkspace } from '../../shared';
|
import type { BlockSuiteWorkspace } from '../../shared';
|
||||||
import type { PivotsProps } from '../pure/workspace-slider-bar/Pivots';
|
import type { PinboardProps } from '../pure/workspace-slider-bar/Pinboard';
|
||||||
import Pivots from '../pure/workspace-slider-bar/Pivots';
|
import Pinboard from '../pure/workspace-slider-bar/Pinboard';
|
||||||
|
|
||||||
expect.extend(matchers);
|
expect.extend(matchers);
|
||||||
|
|
||||||
@@ -35,17 +35,18 @@ const ProviderWrapper: FC<PropsWithChildren> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initPinBoard = async () => {
|
const initPinBoard = async () => {
|
||||||
// create one workspace with 2 root pages and 2 pivot pages
|
// create one workspace with 2 root pages and 2 pinboard pages
|
||||||
// - hasPivotPage
|
// - hasPinboardPage
|
||||||
// - pivot1
|
// - hasPinboardPage
|
||||||
// - pivot2
|
// - pinboard1
|
||||||
// - noPivotPage
|
// - pinboard2
|
||||||
|
// - noPinboardPage
|
||||||
|
|
||||||
const mutationHook = renderHook(() => useWorkspacesHelper(), {
|
const mutationHook = renderHook(() => useWorkspacesHelper(), {
|
||||||
wrapper: ProviderWrapper,
|
wrapper: ProviderWrapper,
|
||||||
});
|
});
|
||||||
const rootPageIds = ['hasPivotPage', 'noPivotPage'];
|
const rootPageIds = ['hasPinboardPage', 'noPinboardPage'];
|
||||||
const pivotPageIds = ['pivot1', 'pivot2'];
|
const pinboardPageIds = ['pinboard1', 'pinboard2'];
|
||||||
const id = await mutationHook.result.current.createLocalWorkspace('test0');
|
const id = await mutationHook.result.current.createLocalWorkspace('test0');
|
||||||
await store.get(workspacesAtom);
|
await store.get(workspacesAtom);
|
||||||
mutationHook.rerender();
|
mutationHook.rerender();
|
||||||
@@ -59,25 +60,32 @@ const initPinBoard = async () => {
|
|||||||
const blockSuiteWorkspace =
|
const blockSuiteWorkspace =
|
||||||
currentWorkspace?.blockSuiteWorkspace as BlockSuiteWorkspace;
|
currentWorkspace?.blockSuiteWorkspace as BlockSuiteWorkspace;
|
||||||
|
|
||||||
|
// create root pinboard
|
||||||
|
mutationHook.result.current.createWorkspacePage(id, 'rootPinboard');
|
||||||
|
blockSuiteWorkspace.meta.setPageMeta('rootPinboard', {
|
||||||
|
isRootPinboard: true,
|
||||||
|
subpageIds: rootPageIds,
|
||||||
|
});
|
||||||
|
// create parent
|
||||||
rootPageIds.forEach(rootPageId => {
|
rootPageIds.forEach(rootPageId => {
|
||||||
mutationHook.result.current.createWorkspacePage(id, rootPageId);
|
mutationHook.result.current.createWorkspacePage(id, rootPageId);
|
||||||
blockSuiteWorkspace.meta.setPageMeta(rootPageId, {
|
blockSuiteWorkspace.meta.setPageMeta(rootPageId, {
|
||||||
isPivots: true,
|
subpageIds: rootPageId === rootPageIds[0] ? pinboardPageIds : [],
|
||||||
subpageIds: rootPageId === rootPageIds[0] ? pivotPageIds : [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
pivotPageIds.forEach(pivotId => {
|
// create children to firs parent
|
||||||
mutationHook.result.current.createWorkspacePage(id, pivotId);
|
pinboardPageIds.forEach(pinboardId => {
|
||||||
blockSuiteWorkspace.meta.setPageMeta(pivotId, {
|
mutationHook.result.current.createWorkspacePage(id, pinboardId);
|
||||||
title: pivotId,
|
blockSuiteWorkspace.meta.setPageMeta(pinboardId, {
|
||||||
|
title: pinboardId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const App = (props: PivotsProps) => {
|
const App = (props: PinboardProps) => {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ProviderWrapper>
|
<ProviderWrapper>
|
||||||
<Pivots {...props} />
|
<Pinboard {...props} />
|
||||||
</ProviderWrapper>
|
</ProviderWrapper>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
@@ -93,53 +101,51 @@ const initPinBoard = async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
rootPageIds,
|
rootPageIds,
|
||||||
pivotPageIds,
|
pinboardPageIds,
|
||||||
app,
|
app,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const openOperationMenu = async (app: RenderResult, pageId: string) => {
|
const openOperationMenu = async (app: RenderResult, pageId: string) => {
|
||||||
const rootPivot = await app.findByTestId(`pivot-${pageId}`);
|
const rootPinboard = await app.findByTestId(`pinboard-${pageId}`);
|
||||||
const operationBtn = (await rootPivot.querySelector(
|
const operationBtn = (await rootPinboard.querySelector(
|
||||||
'[data-testid="pivot-operation-button"]'
|
'[data-testid="pinboard-operation-button"]'
|
||||||
)) as HTMLElement;
|
)) as HTMLElement;
|
||||||
await operationBtn.click();
|
await operationBtn.click();
|
||||||
const menu = await app.findByTestId('pivot-operation-menu');
|
const menu = await app.findByTestId('pinboard-operation-menu');
|
||||||
expect(menu).toBeInTheDocument();
|
expect(menu).toBeInTheDocument();
|
||||||
};
|
};
|
||||||
describe('PinBoard', () => {
|
describe('PinBoard', () => {
|
||||||
test('add pivot', async () => {
|
test('add pinboard', async () => {
|
||||||
const { app, blockSuiteWorkspace, rootPageIds, pivotPageIds } =
|
const { app, blockSuiteWorkspace, rootPageIds } = await initPinBoard();
|
||||||
await initPinBoard();
|
const [hasChildrenPageId] = rootPageIds;
|
||||||
const [hasPivotPageId] = rootPageIds;
|
await openOperationMenu(app, hasChildrenPageId);
|
||||||
await openOperationMenu(app, hasPivotPageId);
|
|
||||||
|
|
||||||
const addBtn = await app.findByTestId('pivot-operation-add');
|
const addBtn = await app.findByTestId('pinboard-operation-add');
|
||||||
await addBtn.click();
|
await addBtn.click();
|
||||||
|
|
||||||
const metas = blockSuiteWorkspace.meta.pageMetas ?? [];
|
const hasChildrenPageMeta =
|
||||||
const rootPageMeta = blockSuiteWorkspace.meta.getPageMeta(hasPivotPageId);
|
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
|
||||||
const addedPageMeta = metas.find(
|
|
||||||
meta => !pivotPageIds.includes(meta.id) && !rootPageIds.includes(meta.id)
|
|
||||||
) as PageMeta;
|
|
||||||
|
|
||||||
// Page meta have been added
|
// Page meta have been added
|
||||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(5);
|
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(6);
|
||||||
// New page meta is added in initial page meta
|
// New page meta is added in initial page meta
|
||||||
|
|
||||||
expect(rootPageMeta?.subpageIds.includes(addedPageMeta.id)).toBe(true);
|
expect(hasChildrenPageMeta?.subpageIds.length).toBe(3);
|
||||||
app.unmount();
|
app.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete pivot', async () => {
|
test('delete pinboard', async () => {
|
||||||
const {
|
const {
|
||||||
app,
|
app,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
rootPageIds: [hasPivotPageId],
|
rootPageIds: [hasChildrenPageId],
|
||||||
} = await initPinBoard();
|
} = await initPinBoard();
|
||||||
await openOperationMenu(app, hasPivotPageId);
|
await openOperationMenu(app, hasChildrenPageId);
|
||||||
|
|
||||||
const deleteBtn = await app.findByTestId('pivot-operation-move-to-trash');
|
const deleteBtn = await app.findByTestId(
|
||||||
|
'pinboard-operation-move-to-trash'
|
||||||
|
);
|
||||||
await deleteBtn.click();
|
await deleteBtn.click();
|
||||||
|
|
||||||
const confirmBtn = await app.findByTestId('move-to-trash-confirm');
|
const confirmBtn = await app.findByTestId('move-to-trash-confirm');
|
||||||
@@ -153,77 +159,78 @@ describe('PinBoard', () => {
|
|||||||
app.unmount();
|
app.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rename pivot', async () => {
|
test('rename pinboard', async () => {
|
||||||
const {
|
const {
|
||||||
app,
|
app,
|
||||||
rootPageIds: [hasPivotPageId],
|
rootPageIds: [hasChildrenPageId],
|
||||||
} = await initPinBoard();
|
} = await initPinBoard();
|
||||||
await openOperationMenu(app, hasPivotPageId);
|
await openOperationMenu(app, hasChildrenPageId);
|
||||||
|
|
||||||
const renameBtn = await app.findByTestId('pivot-operation-rename');
|
const renameBtn = await app.findByTestId('pinboard-operation-rename');
|
||||||
await renameBtn.click();
|
await renameBtn.click();
|
||||||
|
|
||||||
const input = await app.findByTestId(`pivot-input-${hasPivotPageId}`);
|
const input = await app.findByTestId(`pinboard-input-${hasChildrenPageId}`);
|
||||||
expect(input).toBeInTheDocument();
|
expect(input).toBeInTheDocument();
|
||||||
|
|
||||||
// TODO: Fix this test
|
// TODO: Fix this test
|
||||||
// fireEvent.change(input, { target: { value: 'tteesstt' } });
|
// fireEvent.change(input, { target: { value: 'tteesstt' } });
|
||||||
//
|
|
||||||
// expect(
|
// expect(
|
||||||
// blockSuiteWorkspace.meta.getPageMeta(rootPageId)?.name
|
// blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId)?.name
|
||||||
// ).toBe('tteesstt');
|
// ).toBe('tteesstt');
|
||||||
app.unmount();
|
app.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('move pivot', async () => {
|
test('move pinboard', async () => {
|
||||||
const {
|
const {
|
||||||
app,
|
app,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
rootPageIds: [hasPivotPageId],
|
rootPageIds: [hasChildrenPageId],
|
||||||
pivotPageIds: [pivotId1, pivotId2],
|
pinboardPageIds: [pinboardId1, pinboardId2],
|
||||||
} = await initPinBoard();
|
} = await initPinBoard();
|
||||||
await openOperationMenu(app, pivotId1);
|
await openOperationMenu(app, pinboardId1);
|
||||||
|
|
||||||
const moveToBtn = await app.findByTestId('pivot-operation-move-to');
|
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
|
||||||
await moveToBtn.click();
|
await moveToBtn.click();
|
||||||
|
|
||||||
const pivotsMenu = await app.findByTestId('pivots-menu');
|
const pinboardMenu = await app.findByTestId('pinboard-menu');
|
||||||
expect(pivotsMenu).toBeInTheDocument();
|
expect(pinboardMenu).toBeInTheDocument();
|
||||||
|
|
||||||
await (
|
await (
|
||||||
pivotsMenu.querySelector(
|
pinboardMenu.querySelector(
|
||||||
`[data-testid="pivot-${pivotId2}"]`
|
`[data-testid="pinboard-${pinboardId2}"]`
|
||||||
) as HTMLElement
|
) as HTMLElement
|
||||||
).click();
|
).click();
|
||||||
|
|
||||||
const rootPageMeta = blockSuiteWorkspace.meta.getPageMeta(hasPivotPageId);
|
const hasChildrenPageMeta =
|
||||||
|
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
|
||||||
|
|
||||||
expect(rootPageMeta?.subpageIds.includes(pivotId1)).toBe(false);
|
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
|
||||||
expect(rootPageMeta?.subpageIds.includes(pivotId2)).toBe(true);
|
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId2)).toBe(true);
|
||||||
app.unmount();
|
app.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('remove from pivots', async () => {
|
test('remove from pinboard', async () => {
|
||||||
const {
|
const {
|
||||||
app,
|
app,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
rootPageIds: [hasPivotPageId],
|
rootPageIds: [hasChildrenPageId],
|
||||||
pivotPageIds: [pivotId1],
|
pinboardPageIds: [pinboardId1],
|
||||||
} = await initPinBoard();
|
} = await initPinBoard();
|
||||||
await openOperationMenu(app, pivotId1);
|
await openOperationMenu(app, pinboardId1);
|
||||||
|
|
||||||
const moveToBtn = await app.findByTestId('pivot-operation-move-to');
|
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
|
||||||
await moveToBtn.click();
|
await moveToBtn.click();
|
||||||
|
|
||||||
const removeFromPivotsBtn = await app.findByTestId(
|
const removeFromPinboardBtn = await app.findByTestId(
|
||||||
'remove-from-pivots-button'
|
'remove-from-pinboard-button'
|
||||||
);
|
);
|
||||||
removeFromPivotsBtn.click();
|
removeFromPinboardBtn.click();
|
||||||
|
|
||||||
const hasPivotsPageMeta =
|
const hasPinboardPageMeta =
|
||||||
blockSuiteWorkspace.meta.getPageMeta(hasPivotPageId);
|
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
|
||||||
|
|
||||||
expect(hasPivotsPageMeta?.subpageIds.length).toBe(1);
|
expect(hasPinboardPageMeta?.subpageIds.length).toBe(1);
|
||||||
expect(hasPivotsPageMeta?.subpageIds.includes(pivotId1)).toBe(false);
|
expect(hasPinboardPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
|
||||||
|
app.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,17 +16,16 @@ export const CopyLink = ({ onItemClick, onSelect }: CommonMenuItemProps) => {
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MenuItem
|
||||||
<MenuItem
|
data-testid="copy-link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyUrl();
|
copyUrl();
|
||||||
onItemClick?.();
|
onItemClick?.();
|
||||||
onSelect?.();
|
onSelect?.();
|
||||||
}}
|
}}
|
||||||
icon={<CopyIcon />}
|
icon={<CopyIcon />}
|
||||||
>
|
>
|
||||||
{t('Copy Link')}
|
{t('Copy Link')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { MenuItem } from '@affine/component';
|
|||||||
import { useTranslation } from '@affine/i18n';
|
import { useTranslation } from '@affine/i18n';
|
||||||
import { ArrowRightSmallIcon, MoveToIcon } from '@blocksuite/icons';
|
import { ArrowRightSmallIcon, MoveToIcon } from '@blocksuite/icons';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import { useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||||
import { PivotsMenu } from '../pivots';
|
import { PinboardMenu } from '../pinboard';
|
||||||
import type { CommonMenuItemProps } from './types';
|
import type { CommonMenuItemProps } from './types';
|
||||||
|
|
||||||
export type MoveToProps = CommonMenuItemProps<{
|
export type MoveToProps = CommonMenuItemProps<{
|
||||||
@@ -43,14 +43,17 @@ export const MoveTo = ({
|
|||||||
>
|
>
|
||||||
{t('Move to')}
|
{t('Move to')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<PivotsMenu
|
<PinboardMenu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={open}
|
open={open}
|
||||||
placement="left-start"
|
placement="left-start"
|
||||||
metas={metas.filter(meta => !meta.trash)}
|
metas={useMemo(
|
||||||
|
() => metas.filter(m => !m.trash && m.id !== currentMeta.id),
|
||||||
|
[metas, currentMeta]
|
||||||
|
)}
|
||||||
currentMeta={currentMeta}
|
currentMeta={currentMeta}
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||||
onPivotClick={onSelect}
|
onPinboardClick={onSelect}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
2
apps/web/src/components/affine/pinboard/index.ts
Normal file
2
apps/web/src/components/affine/pinboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './pinboard-menu';
|
||||||
|
export * from './pinboard-render/';
|
||||||
@@ -7,7 +7,7 @@ import Image from 'next/legacy/image';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { workspacePreferredModeAtom } from '../../../../atoms';
|
import { workspacePreferredModeAtom } from '../../../../atoms';
|
||||||
import { StyledMenuSubTitle, StyledPivot } from '../styles';
|
import { StyledMenuSubTitle, StyledPinboard } from '../styles';
|
||||||
|
|
||||||
export const SearchContent = ({
|
export const SearchContent = ({
|
||||||
results,
|
results,
|
||||||
@@ -27,16 +27,16 @@ export const SearchContent = ({
|
|||||||
</StyledMenuSubTitle>
|
</StyledMenuSubTitle>
|
||||||
{results.map(meta => {
|
{results.map(meta => {
|
||||||
return (
|
return (
|
||||||
<StyledPivot
|
<StyledPinboard
|
||||||
key={meta.id}
|
key={meta.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClick?.(meta.id);
|
onClick?.(meta.id);
|
||||||
}}
|
}}
|
||||||
data-testid="pivot-search-result"
|
data-testid="pinboard-search-result"
|
||||||
>
|
>
|
||||||
{record[meta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
|
{record[meta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
|
||||||
{meta.title}
|
{meta.title}
|
||||||
</StyledPivot>
|
</StyledPinboard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -1,42 +1,41 @@
|
|||||||
import type { PureMenuProps } from '@affine/component';
|
import type { PureMenuProps } from '@affine/component';
|
||||||
import { Input, PureMenu } from '@affine/component';
|
import { Input, PureMenu, TreeView } from '@affine/component';
|
||||||
import { useTranslation } from '@affine/i18n';
|
import { useTranslation } from '@affine/i18n';
|
||||||
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
|
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { usePinboardData } from '../../../../hooks/affine/use-pinboard-data';
|
||||||
|
import { usePinboardHandler } from '../../../../hooks/affine/use-pinboard-handler';
|
||||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
||||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
import type { BlockSuiteWorkspace } from '../../../../shared';
|
||||||
import { toast } from '../../../../utils';
|
import { toast } from '../../../../utils';
|
||||||
import { usePivotData } from '../hooks/usePivotData';
|
import { PinboardRender } from '../pinboard-render/';
|
||||||
import { usePivotHandler } from '../hooks/usePivotHandler';
|
|
||||||
import { PivotRender } from '../pivot-render/PivotRender';
|
|
||||||
import {
|
import {
|
||||||
StyledMenuContent,
|
StyledMenuContent,
|
||||||
StyledMenuFooter,
|
StyledMenuFooter,
|
||||||
StyledMenuSubTitle,
|
StyledMenuSubTitle,
|
||||||
StyledPivot,
|
StyledPinboard,
|
||||||
StyledSearchContainer,
|
StyledSearchContainer,
|
||||||
} from '../styles';
|
} from '../styles';
|
||||||
import { Pivots } from './Pivots';
|
|
||||||
import { SearchContent } from './SearchContent';
|
import { SearchContent } from './SearchContent';
|
||||||
|
|
||||||
export type PivotsMenuProps = {
|
export type PinboardMenuProps = {
|
||||||
metas: PageMeta[];
|
metas: PageMeta[];
|
||||||
currentMeta: PageMeta;
|
currentMeta: PageMeta;
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||||
showRemovePivots?: boolean;
|
showRemovePinboard?: boolean;
|
||||||
onPivotClick?: (p: { dragId: string; dropId: string }) => void;
|
onPinboardClick?: (p: { dragId: string; dropId: string }) => void;
|
||||||
} & PureMenuProps;
|
} & PureMenuProps;
|
||||||
|
|
||||||
export const PivotsMenu = ({
|
export const PinboardMenu = ({
|
||||||
metas,
|
metas,
|
||||||
currentMeta,
|
currentMeta,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
showRemovePivots = false,
|
showRemovePinboard = false,
|
||||||
onPivotClick,
|
onPinboardClick,
|
||||||
...pureMenuProps
|
...pureMenuProps
|
||||||
}: PivotsMenuProps) => {
|
}: PinboardMenuProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -46,7 +45,7 @@ export const PivotsMenu = ({
|
|||||||
meta => !meta.trash && meta.title.includes(query)
|
meta => !meta.trash && meta.title.includes(query)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { handleDrop } = usePivotHandler({
|
const { handleDrop } = usePinboardHandler({
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
metas,
|
metas,
|
||||||
});
|
});
|
||||||
@@ -60,15 +59,15 @@ export const PivotsMenu = ({
|
|||||||
topLine: false,
|
topLine: false,
|
||||||
internal: true,
|
internal: true,
|
||||||
});
|
});
|
||||||
onPivotClick?.({ dragId: currentMeta.id, dropId });
|
onPinboardClick?.({ dragId: currentMeta.id, dropId });
|
||||||
toast(`Moved "${currentMeta.title}" to "${targetTitle}"`);
|
toast(`Moved "${currentMeta.title}" to "${targetTitle}"`);
|
||||||
},
|
},
|
||||||
[currentMeta.id, currentMeta.title, handleDrop, metas, onPivotClick]
|
[currentMeta.id, currentMeta.title, handleDrop, metas, onPinboardClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data } = usePivotData({
|
const { data } = usePinboardData({
|
||||||
metas,
|
metas,
|
||||||
pivotRender: PivotRender,
|
pinboardRender: PinboardRender,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
onClick: (e, node) => {
|
onClick: (e, node) => {
|
||||||
handleClick(node.id);
|
handleClick(node.id);
|
||||||
@@ -80,7 +79,7 @@ export const PivotsMenu = ({
|
|||||||
width={320}
|
width={320}
|
||||||
height={480}
|
height={480}
|
||||||
{...pureMenuProps}
|
{...pureMenuProps}
|
||||||
data-testid="pivots-menu"
|
data-testid="pinboard-menu"
|
||||||
>
|
>
|
||||||
<StyledSearchContainer>
|
<StyledSearchContainer>
|
||||||
<label>
|
<label>
|
||||||
@@ -93,7 +92,7 @@ export const PivotsMenu = ({
|
|||||||
height={32}
|
height={32}
|
||||||
noBorder={true}
|
noBorder={true}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
data-testid="pivots-menu-search"
|
data-testid="pinboard-menu-search"
|
||||||
/>
|
/>
|
||||||
</StyledSearchContainer>
|
</StyledSearchContainer>
|
||||||
|
|
||||||
@@ -104,21 +103,16 @@ export const PivotsMenu = ({
|
|||||||
{!isSearching && (
|
{!isSearching && (
|
||||||
<>
|
<>
|
||||||
<StyledMenuSubTitle>Suggested</StyledMenuSubTitle>
|
<StyledMenuSubTitle>Suggested</StyledMenuSubTitle>
|
||||||
<Pivots
|
<TreeView data={data} indent={16} enableDnd={false} />
|
||||||
data={data}
|
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
|
||||||
currentMeta={currentMeta}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</StyledMenuContent>
|
</StyledMenuContent>
|
||||||
|
|
||||||
{showRemovePivots && (
|
{showRemovePinboard && (
|
||||||
<StyledMenuFooter>
|
<StyledMenuFooter>
|
||||||
<StyledPivot
|
<StyledPinboard
|
||||||
data-testid={'remove-from-pivots-button'}
|
data-testid={'remove-from-pinboard-button'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPageMeta(currentMeta.id, { isPivots: false });
|
|
||||||
const parentMeta = metas.find(m =>
|
const parentMeta = metas.find(m =>
|
||||||
m.subpageIds.includes(currentMeta.id)
|
m.subpageIds.includes(currentMeta.id)
|
||||||
);
|
);
|
||||||
@@ -132,11 +126,13 @@ export const PivotsMenu = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RemoveIcon />
|
<RemoveIcon />
|
||||||
{t('Remove from Pivots')}
|
{t('Remove from Pinboard')}
|
||||||
</StyledPivot>
|
</StyledPinboard>
|
||||||
<p>{t('RFP')}</p>
|
<p>{t('RFP')}</p>
|
||||||
</StyledMenuFooter>
|
</StyledMenuFooter>
|
||||||
)}
|
)}
|
||||||
</PureMenu>
|
</PureMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default PinboardMenu;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useTranslation } from '@affine/i18n';
|
||||||
|
|
||||||
|
import { StyledPinboard } from '../styles';
|
||||||
|
|
||||||
|
export const EmptyItem = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<StyledPinboard disable={true} style={{ paddingLeft: '32px' }}>
|
||||||
|
{t('No item')}
|
||||||
|
</StyledPinboard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyItem;
|
||||||
@@ -14,10 +14,11 @@ import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
|||||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
import type { BlockSuiteWorkspace } from '../../../../shared';
|
||||||
import { toast } from '../../../../utils';
|
import { toast } from '../../../../utils';
|
||||||
import { CopyLink, MoveToTrash } from '../../operation-menu-items';
|
import { CopyLink, MoveToTrash } from '../../operation-menu-items';
|
||||||
import { PivotsMenu } from '../PivotsMenu/PivotsMenu';
|
import { PinboardMenu } from '../pinboard-menu/';
|
||||||
import { StyledOperationButton } from '../styles';
|
import { StyledOperationButton } from '../styles';
|
||||||
|
|
||||||
export type OperationButtonProps = {
|
export type OperationButtonProps = {
|
||||||
|
isRoot: boolean;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
metas: PageMeta[];
|
metas: PageMeta[];
|
||||||
@@ -28,6 +29,7 @@ export type OperationButtonProps = {
|
|||||||
onMenuClose?: () => void;
|
onMenuClose?: () => void;
|
||||||
};
|
};
|
||||||
export const OperationButton = ({
|
export const OperationButton = ({
|
||||||
|
isRoot,
|
||||||
onAdd,
|
onAdd,
|
||||||
onDelete,
|
onDelete,
|
||||||
metas,
|
metas,
|
||||||
@@ -44,7 +46,7 @@ export const OperationButton = ({
|
|||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [operationMenuOpen, setOperationMenuOpen] = useState(false);
|
const [operationMenuOpen, setOperationMenuOpen] = useState(false);
|
||||||
const [pivotsMenuOpen, setPivotsMenuOpen] = useState(false);
|
const [pinboardMenuOpen, setPinboardMenuOpen] = useState(false);
|
||||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||||
const menuIndex = useMemo(() => modalIndex + 1, [modalIndex]);
|
const menuIndex = useMemo(() => modalIndex + 1, [modalIndex]);
|
||||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||||
@@ -53,7 +55,7 @@ export const OperationButton = ({
|
|||||||
<MuiClickAwayListener
|
<MuiClickAwayListener
|
||||||
onClickAway={() => {
|
onClickAway={() => {
|
||||||
setOperationMenuOpen(false);
|
setOperationMenuOpen(false);
|
||||||
setPivotsMenuOpen(false);
|
setPinboardMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -62,11 +64,11 @@ export const OperationButton = ({
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
setOperationMenuOpen(false);
|
setOperationMenuOpen(false);
|
||||||
setPivotsMenuOpen(false);
|
setPinboardMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledOperationButton
|
<StyledOperationButton
|
||||||
data-testid="pivot-operation-button"
|
data-testid="pinboard-operation-button"
|
||||||
ref={ref => setAnchorEl(ref)}
|
ref={ref => setAnchorEl(ref)}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -78,7 +80,7 @@ export const OperationButton = ({
|
|||||||
</StyledOperationButton>
|
</StyledOperationButton>
|
||||||
|
|
||||||
<PureMenu
|
<PureMenu
|
||||||
data-testid="pivot-operation-menu"
|
data-testid="pinboard-operation-menu"
|
||||||
width={256}
|
width={256}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={operationMenuOpen}
|
open={operationMenuOpen}
|
||||||
@@ -86,7 +88,7 @@ export const OperationButton = ({
|
|||||||
zIndex={menuIndex}
|
zIndex={menuIndex}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
data-testid="pivot-operation-add"
|
data-testid="pinboard-operation-add"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAdd();
|
onAdd();
|
||||||
setOperationMenuOpen(false);
|
setOperationMenuOpen(false);
|
||||||
@@ -96,47 +98,53 @@ export const OperationButton = ({
|
|||||||
>
|
>
|
||||||
{t('Add a subpage inside')}
|
{t('Add a subpage inside')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
{!isRoot && (
|
||||||
data-testid="pivot-operation-move-to"
|
<MenuItem
|
||||||
onClick={() => {
|
data-testid="pinboard-operation-move-to"
|
||||||
setOperationMenuOpen(false);
|
onClick={() => {
|
||||||
setPivotsMenuOpen(true);
|
setOperationMenuOpen(false);
|
||||||
}}
|
setPinboardMenuOpen(true);
|
||||||
icon={<MoveToIcon />}
|
}}
|
||||||
>
|
icon={<MoveToIcon />}
|
||||||
{t('Move to')}
|
>
|
||||||
</MenuItem>
|
{t('Move to')}
|
||||||
<MenuItem
|
</MenuItem>
|
||||||
data-testid="pivot-operation-rename"
|
)}
|
||||||
onClick={() => {
|
{!isRoot && (
|
||||||
onRename?.();
|
<MenuItem
|
||||||
setOperationMenuOpen(false);
|
data-testid="pinboard-operation-rename"
|
||||||
onMenuClose?.();
|
onClick={() => {
|
||||||
}}
|
onRename?.();
|
||||||
icon={<PenIcon />}
|
setOperationMenuOpen(false);
|
||||||
>
|
onMenuClose?.();
|
||||||
{t('Rename')}
|
}}
|
||||||
</MenuItem>
|
icon={<PenIcon />}
|
||||||
<MoveToTrash
|
>
|
||||||
testId="pivot-operation-move-to-trash"
|
{t('Rename')}
|
||||||
onItemClick={() => {
|
</MenuItem>
|
||||||
setOperationMenuOpen(false);
|
)}
|
||||||
setConfirmModalOpen(true);
|
{!isRoot && (
|
||||||
onMenuClose?.();
|
<MoveToTrash
|
||||||
}}
|
testId="pinboard-operation-move-to-trash"
|
||||||
/>
|
onItemClick={() => {
|
||||||
|
setOperationMenuOpen(false);
|
||||||
|
setConfirmModalOpen(true);
|
||||||
|
onMenuClose?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<CopyLink />
|
<CopyLink />
|
||||||
</PureMenu>
|
</PureMenu>
|
||||||
|
|
||||||
<PivotsMenu
|
<PinboardMenu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={pivotsMenuOpen}
|
open={pinboardMenuOpen}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
zIndex={menuIndex}
|
zIndex={menuIndex}
|
||||||
metas={metas}
|
metas={metas}
|
||||||
currentMeta={currentMeta}
|
currentMeta={currentMeta}
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||||
showRemovePivots={true}
|
showRemovePinboard={true}
|
||||||
/>
|
/>
|
||||||
<MoveToTrash.ConfirmModal
|
<MoveToTrash.ConfirmModal
|
||||||
open={confirmModalOpen}
|
open={confirmModalOpen}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Input } from '@affine/component';
|
||||||
|
import {
|
||||||
|
ArrowDownSmallIcon,
|
||||||
|
EdgelessIcon,
|
||||||
|
PageIcon,
|
||||||
|
PivotsIcon,
|
||||||
|
} from '@blocksuite/icons';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { workspacePreferredModeAtom } from '../../../../atoms';
|
||||||
|
import type { PinboardNode } from '../../../../hooks/affine/use-pinboard-data';
|
||||||
|
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
||||||
|
import { StyledCollapsedButton, StyledPinboard } from '../styles';
|
||||||
|
import EmptyItem from './EmptyItem';
|
||||||
|
import { OperationButton } from './OperationButton';
|
||||||
|
|
||||||
|
const getIcon = (type: 'root' | 'edgeless' | 'page') => {
|
||||||
|
switch (type) {
|
||||||
|
case 'root':
|
||||||
|
return <PivotsIcon />;
|
||||||
|
case 'edgeless':
|
||||||
|
return <EdgelessIcon />;
|
||||||
|
default:
|
||||||
|
return <PageIcon />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PinboardRender: PinboardNode['render'] = (
|
||||||
|
node,
|
||||||
|
{ isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected },
|
||||||
|
renderProps
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
onClick,
|
||||||
|
showOperationButton = false,
|
||||||
|
currentMeta,
|
||||||
|
metas = [],
|
||||||
|
blockSuiteWorkspace,
|
||||||
|
} = renderProps!;
|
||||||
|
const record = useAtomValue(workspacePreferredModeAtom);
|
||||||
|
const { setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isHover, setIsHover] = useState(false);
|
||||||
|
const [showRename, setShowRename] = useState(false);
|
||||||
|
|
||||||
|
const active = router.query.pageId === node.id;
|
||||||
|
const isRoot = !!currentMeta.isRootPinboard;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledPinboard
|
||||||
|
data-testid={`pinboard-${node.id}`}
|
||||||
|
onClick={e => {
|
||||||
|
onClick?.(e, node);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsHover(true)}
|
||||||
|
onMouseLeave={() => setIsHover(false)}
|
||||||
|
isOver={isOver || isSelected}
|
||||||
|
active={active}
|
||||||
|
>
|
||||||
|
<StyledCollapsedButton
|
||||||
|
collapse={collapsed}
|
||||||
|
show={!!node.children?.length}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsed(node.id, !collapsed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownSmallIcon />
|
||||||
|
</StyledCollapsedButton>
|
||||||
|
{getIcon(isRoot ? 'root' : record[node.id])}
|
||||||
|
|
||||||
|
{showRename ? (
|
||||||
|
<Input
|
||||||
|
data-testid={`pinboard-input-${node.id}`}
|
||||||
|
value={currentMeta.title || ''}
|
||||||
|
placeholder="Untitled"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
height={32}
|
||||||
|
onBlur={() => {
|
||||||
|
setShowRename(false);
|
||||||
|
}}
|
||||||
|
onChange={value => {
|
||||||
|
// FIXME: setPageTitle would make input blur, and can't input the Chinese character
|
||||||
|
setPageTitle(node.id, value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{isRoot ? 'Pinboard' : currentMeta.title || 'Untitled'}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showOperationButton && (
|
||||||
|
<OperationButton
|
||||||
|
isRoot={isRoot}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onDelete={onDelete}
|
||||||
|
metas={metas}
|
||||||
|
currentMeta={currentMeta!}
|
||||||
|
blockSuiteWorkspace={blockSuiteWorkspace!}
|
||||||
|
isHover={isHover}
|
||||||
|
onMenuClose={() => setIsHover(false)}
|
||||||
|
onRename={() => {
|
||||||
|
setShowRename(true);
|
||||||
|
setIsHover(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledPinboard>
|
||||||
|
|
||||||
|
{useMemo(
|
||||||
|
() =>
|
||||||
|
isRoot &&
|
||||||
|
!metas.find(m => (currentMeta.subpageIds ?? []).includes(m.id)),
|
||||||
|
[currentMeta.subpageIds, isRoot, metas]
|
||||||
|
) && <EmptyItem />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default PinboardRender;
|
||||||
@@ -21,14 +21,14 @@ export const StyledCollapsedButton = styled('button')<{
|
|||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
color: theme.colors.iconColor,
|
color: theme.colors.iconColor,
|
||||||
opacity: '.6',
|
opacity: '.6',
|
||||||
display: show ? 'block' : 'none',
|
display: show ? 'flex' : 'none',
|
||||||
svg: {
|
svg: {
|
||||||
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
|
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StyledPivot = styled('div')<{
|
export const StyledPinboard = styled('div')<{
|
||||||
disable?: boolean;
|
disable?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
isOver?: boolean;
|
isOver?: boolean;
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useTranslation } from '@affine/i18n';
|
|
||||||
|
|
||||||
import { StyledPivot } from '../styles';
|
|
||||||
|
|
||||||
export const EmptyItem = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return <StyledPivot disable={true}>{t('No item')}</StyledPivot>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmptyItem;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { TreeViewProps } from '@affine/component';
|
|
||||||
import { MuiCollapse, TreeView } from '@affine/component';
|
|
||||||
import { useTranslation } from '@affine/i18n';
|
|
||||||
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
|
|
||||||
import type { MouseEvent } from 'react';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
|
||||||
import { toast } from '../../../../utils';
|
|
||||||
import { StyledCollapsedButton, StyledPivot } from '../styles';
|
|
||||||
import type { NodeRenderProps } from '../types';
|
|
||||||
import EmptyItem from './EmptyItem';
|
|
||||||
import type { PivotsMenuProps } from './PivotsMenu';
|
|
||||||
|
|
||||||
export type PivotsProps = {
|
|
||||||
data: TreeViewProps<NodeRenderProps>['data'];
|
|
||||||
} & Pick<PivotsMenuProps, 'blockSuiteWorkspace' | 'currentMeta'>;
|
|
||||||
export const Pivots = ({
|
|
||||||
data,
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
currentMeta,
|
|
||||||
}: PivotsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
|
||||||
const [showPivot, setShowPivot] = useState(true);
|
|
||||||
const isPivotEmpty = useMemo(() => data.length === 0, [data]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StyledPivot
|
|
||||||
data-testid="root-pivot-button-in-pivots-menu"
|
|
||||||
id="root-pivot-button-in-pivots-menu"
|
|
||||||
onClick={() => {
|
|
||||||
setPageMeta(currentMeta.id, { isPivots: true });
|
|
||||||
toast(`Moved "${currentMeta.title}" to Pivots`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StyledCollapsedButton
|
|
||||||
onClick={useCallback(
|
|
||||||
(e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowPivot(!showPivot);
|
|
||||||
},
|
|
||||||
[showPivot]
|
|
||||||
)}
|
|
||||||
collapse={showPivot}
|
|
||||||
>
|
|
||||||
<ArrowDownSmallIcon />
|
|
||||||
</StyledCollapsedButton>
|
|
||||||
<PivotsIcon />
|
|
||||||
{t('Pivots')}
|
|
||||||
</StyledPivot>
|
|
||||||
|
|
||||||
<MuiCollapse
|
|
||||||
in={showPivot}
|
|
||||||
style={{
|
|
||||||
maxHeight: 300,
|
|
||||||
paddingLeft: '16px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPivotEmpty ? (
|
|
||||||
<EmptyItem />
|
|
||||||
) : (
|
|
||||||
<TreeView data={data} indent={16} enableDnd={false} />
|
|
||||||
)}
|
|
||||||
</MuiCollapse>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Pivots;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import type { RenderProps, TreeNode } from '../types';
|
|
||||||
|
|
||||||
const flattenToTree = (
|
|
||||||
metas: PageMeta[],
|
|
||||||
pivotRender: TreeNode['render'],
|
|
||||||
renderProps: RenderProps
|
|
||||||
): TreeNode[] => {
|
|
||||||
// Compatibility process: the old data not has `subpageIds`, it is a root page
|
|
||||||
const rootMetas = metas
|
|
||||||
.filter(meta => {
|
|
||||||
if (meta.subpageIds) {
|
|
||||||
return (
|
|
||||||
metas.find(m => {
|
|
||||||
return m.subpageIds?.includes(meta.id);
|
|
||||||
}) === undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.filter(meta => meta.isPivots === true);
|
|
||||||
|
|
||||||
const helper = (internalMetas: PageMeta[]): TreeNode[] => {
|
|
||||||
return internalMetas.reduce<TreeNode[]>((returnedMetas, internalMeta) => {
|
|
||||||
const { subpageIds = [] } = internalMeta;
|
|
||||||
const childrenMetas = subpageIds
|
|
||||||
.map(id => metas.find(m => m.id === id)!)
|
|
||||||
.filter(m => m);
|
|
||||||
// @ts-ignore
|
|
||||||
const returnedMeta: TreeNode = {
|
|
||||||
...internalMeta,
|
|
||||||
children: helper(childrenMetas),
|
|
||||||
render: (node, props) =>
|
|
||||||
pivotRender(node, props, {
|
|
||||||
...renderProps,
|
|
||||||
currentMeta: internalMeta,
|
|
||||||
metas,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
returnedMetas.push(returnedMeta);
|
|
||||||
return returnedMetas;
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
return helper(rootMetas);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePivotData = ({
|
|
||||||
metas,
|
|
||||||
pivotRender,
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
onClick,
|
|
||||||
showOperationButton,
|
|
||||||
}: {
|
|
||||||
metas: PageMeta[];
|
|
||||||
pivotRender: TreeNode['render'];
|
|
||||||
} & RenderProps) => {
|
|
||||||
const data = useMemo(
|
|
||||||
() =>
|
|
||||||
flattenToTree(metas, pivotRender, {
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
onClick,
|
|
||||||
showOperationButton,
|
|
||||||
}),
|
|
||||||
[blockSuiteWorkspace, metas, onClick, pivotRender, showOperationButton]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default usePivotData;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './hooks/usePivotData';
|
|
||||||
export * from './hooks/usePivotHandler';
|
|
||||||
export * from './pivot-render/PivotRender';
|
|
||||||
export * from './PivotsMenu/PivotsMenu';
|
|
||||||
export * from './types';
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Input } from '@affine/component';
|
|
||||||
import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { workspacePreferredModeAtom } from '../../../../atoms';
|
|
||||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
|
||||||
import { StyledCollapsedButton, StyledPivot } from '../styles';
|
|
||||||
import type { TreeNode } from '../types';
|
|
||||||
import { OperationButton } from './OperationButton';
|
|
||||||
|
|
||||||
export const PivotRender: TreeNode['render'] = (
|
|
||||||
node,
|
|
||||||
{ isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected },
|
|
||||||
renderProps
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
onClick,
|
|
||||||
showOperationButton = false,
|
|
||||||
currentMeta,
|
|
||||||
metas = [],
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
} = renderProps!;
|
|
||||||
const record = useAtomValue(workspacePreferredModeAtom);
|
|
||||||
const { setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [isHover, setIsHover] = useState(false);
|
|
||||||
const [showRename, setShowRename] = useState(false);
|
|
||||||
|
|
||||||
const active = router.query.pageId === node.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledPivot
|
|
||||||
data-testid={`pivot-${node.id}`}
|
|
||||||
onClick={e => {
|
|
||||||
onClick?.(e, node);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setIsHover(true)}
|
|
||||||
onMouseLeave={() => setIsHover(false)}
|
|
||||||
isOver={isOver || isSelected}
|
|
||||||
active={active}
|
|
||||||
>
|
|
||||||
<StyledCollapsedButton
|
|
||||||
collapse={collapsed}
|
|
||||||
show={!!node.children?.length}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setCollapsed(node.id, !collapsed);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowDownSmallIcon />
|
|
||||||
</StyledCollapsedButton>
|
|
||||||
{record[node.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
|
|
||||||
{showRename ? (
|
|
||||||
<Input
|
|
||||||
data-testid={`pivot-input-${node.id}`}
|
|
||||||
value={currentMeta?.title ?? ''}
|
|
||||||
placeholder="Untitled"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
height={32}
|
|
||||||
onBlur={() => {
|
|
||||||
setShowRename(false);
|
|
||||||
}}
|
|
||||||
onChange={value => {
|
|
||||||
// FIXME: setPageTitle would make input blur, and can't input the Chinese character
|
|
||||||
setPageTitle(node.id, value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{currentMeta?.title || 'Untitled'}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showOperationButton && (
|
|
||||||
<OperationButton
|
|
||||||
onAdd={onAdd}
|
|
||||||
onDelete={onDelete}
|
|
||||||
metas={metas}
|
|
||||||
currentMeta={currentMeta!}
|
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace!}
|
|
||||||
isHover={isHover}
|
|
||||||
onMenuClose={() => setIsHover(false)}
|
|
||||||
onRename={() => {
|
|
||||||
setShowRename(true);
|
|
||||||
setIsHover(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</StyledPivot>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { Node } from '@affine/component';
|
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import type { MouseEvent } from 'react';
|
|
||||||
|
|
||||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
|
||||||
|
|
||||||
export type RenderProps = {
|
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
|
||||||
onClick?: (e: MouseEvent<HTMLDivElement>, node: TreeNode) => void;
|
|
||||||
showOperationButton?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodeRenderProps = RenderProps & {
|
|
||||||
metas: PageMeta[];
|
|
||||||
currentMeta: PageMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TreeNode = Node<NodeRenderProps>;
|
|
||||||
@@ -44,6 +44,7 @@ export const OperationCell: React.FC<OperationCellProps> = ({
|
|||||||
const { id, favorite } = pageMeta;
|
const { id, favorite } = pageMeta;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const OperationMenu = (
|
const OperationMenu = (
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -65,20 +66,24 @@ export const OperationCell: React.FC<OperationCellProps> = ({
|
|||||||
>
|
>
|
||||||
{t('Open in new tab')}
|
{t('Open in new tab')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MoveTo
|
{!pageMeta.isRootPinboard && (
|
||||||
metas={metas}
|
<MoveTo
|
||||||
currentMeta={pageMeta}
|
metas={metas}
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
currentMeta={pageMeta}
|
||||||
/>
|
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||||
<MenuItem
|
/>
|
||||||
data-testid="move-to-trash"
|
)}
|
||||||
onClick={() => {
|
{!pageMeta.isRootPinboard && (
|
||||||
setOpen(true);
|
<MenuItem
|
||||||
}}
|
data-testid="move-to-trash"
|
||||||
icon={<DeleteTemporarilyIcon />}
|
onClick={() => {
|
||||||
>
|
setOpen(true);
|
||||||
{t('Move to Trash')}
|
}}
|
||||||
</MenuItem>
|
icon={<DeleteTemporarilyIcon />}
|
||||||
|
>
|
||||||
|
{t('Move to Trash')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +95,7 @@ export const OperationCell: React.FC<OperationCellProps> = ({
|
|||||||
disablePortal={true}
|
disablePortal={true}
|
||||||
trigger="click"
|
trigger="click"
|
||||||
>
|
>
|
||||||
<IconButton>
|
<IconButton data-testid="page-list-operation-button">
|
||||||
<MoreVerticalIcon />
|
<MoreVerticalIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -82,17 +82,21 @@ export const EditorOptionMenu = () => {
|
|||||||
{mode === 'page' ? t('Edgeless') : t('Page')}
|
{mode === 'page' ? t('Edgeless') : t('Page')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Export />
|
<Export />
|
||||||
<MoveTo
|
{!pageMeta.isRootPinboard && (
|
||||||
metas={allMetas}
|
<MoveTo
|
||||||
currentMeta={pageMeta}
|
metas={allMetas}
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
currentMeta={pageMeta}
|
||||||
/>
|
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||||
<MoveToTrash
|
/>
|
||||||
testId="editor-option-menu-delete"
|
)}
|
||||||
onItemClick={() => {
|
{!pageMeta.isRootPinboard && (
|
||||||
setOpenConfirm(true);
|
<MoveToTrash
|
||||||
}}
|
testId="editor-option-menu-delete"
|
||||||
/>
|
onItemClick={() => {
|
||||||
|
setOpenConfirm(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { TreeView } from '@affine/component';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import type { PinboardNode } from '../../../hooks/affine/use-pinboard-data';
|
||||||
|
import { usePinboardData } from '../../../hooks/affine/use-pinboard-data';
|
||||||
|
import { usePinboardHandler } from '../../../hooks/affine/use-pinboard-handler';
|
||||||
|
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||||
|
import { PinboardRender } from '../../affine/pinboard';
|
||||||
|
|
||||||
|
export type PinboardProps = {
|
||||||
|
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||||
|
openPage: (pageId: string) => void;
|
||||||
|
allMetas: PageMeta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Pinboard = ({
|
||||||
|
blockSuiteWorkspace,
|
||||||
|
openPage,
|
||||||
|
allMetas,
|
||||||
|
}: PinboardProps) => {
|
||||||
|
const handlePinboardClick = useCallback(
|
||||||
|
(e: MouseEvent<HTMLDivElement>, node: PinboardNode) => {
|
||||||
|
openPage(node.id);
|
||||||
|
},
|
||||||
|
[openPage]
|
||||||
|
);
|
||||||
|
const onAdd = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
openPage(id);
|
||||||
|
},
|
||||||
|
[openPage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = usePinboardData({
|
||||||
|
metas: allMetas.filter(meta => !meta.trash),
|
||||||
|
pinboardRender: PinboardRender,
|
||||||
|
blockSuiteWorkspace: blockSuiteWorkspace,
|
||||||
|
onClick: handlePinboardClick,
|
||||||
|
showOperationButton: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleAdd, handleDelete, handleDrop } = usePinboardHandler({
|
||||||
|
blockSuiteWorkspace: blockSuiteWorkspace,
|
||||||
|
metas: allMetas,
|
||||||
|
onAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div data-testid="sidebar-pinboard-container">
|
||||||
|
<TreeView
|
||||||
|
data={data}
|
||||||
|
onAdd={handleAdd}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
indent={16}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Pinboard;
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { MuiCollapse, TreeView } from '@affine/component';
|
|
||||||
import { useTranslation } from '@affine/i18n';
|
|
||||||
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
|
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import type { MouseEvent } from 'react';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
|
||||||
import type { TreeNode } from '../../affine/pivots';
|
|
||||||
import {
|
|
||||||
PivotRender,
|
|
||||||
usePivotData,
|
|
||||||
usePivotHandler,
|
|
||||||
} from '../../affine/pivots';
|
|
||||||
import EmptyItem from './favorite/empty-item';
|
|
||||||
import { StyledCollapseButton, StyledListItem } from './shared-styles';
|
|
||||||
|
|
||||||
export const PivotInternal = ({
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
openPage,
|
|
||||||
allMetas,
|
|
||||||
}: {
|
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
|
||||||
openPage: (pageId: string) => void;
|
|
||||||
allMetas: PageMeta[];
|
|
||||||
}) => {
|
|
||||||
const handlePivotClick = useCallback(
|
|
||||||
(e: MouseEvent<HTMLDivElement>, node: TreeNode) => {
|
|
||||||
openPage(node.id);
|
|
||||||
},
|
|
||||||
[openPage]
|
|
||||||
);
|
|
||||||
const onAdd = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
openPage(id);
|
|
||||||
},
|
|
||||||
[openPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data } = usePivotData({
|
|
||||||
metas: allMetas.filter(meta => !meta.trash),
|
|
||||||
pivotRender: PivotRender,
|
|
||||||
blockSuiteWorkspace: blockSuiteWorkspace,
|
|
||||||
onClick: handlePivotClick,
|
|
||||||
showOperationButton: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleAdd, handleDelete, handleDrop } = usePivotHandler({
|
|
||||||
blockSuiteWorkspace: blockSuiteWorkspace,
|
|
||||||
|
|
||||||
metas: allMetas,
|
|
||||||
onAdd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeView
|
|
||||||
data={data}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
indent={16}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PivotsProps = {
|
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
|
||||||
openPage: (pageId: string) => void;
|
|
||||||
allMetas: PageMeta[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Pivots = ({
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
openPage,
|
|
||||||
allMetas,
|
|
||||||
}: PivotsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [showPivot, setShowPivot] = useState(true);
|
|
||||||
const metas = useMemo(() => allMetas.filter(meta => !meta.trash), [allMetas]);
|
|
||||||
const isPivotEmpty = useMemo(
|
|
||||||
() => metas.filter(meta => meta.isPivots === true).length === 0,
|
|
||||||
[metas]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-testid="sidebar-pivots-container">
|
|
||||||
<StyledListItem
|
|
||||||
onClick={useCallback(() => {
|
|
||||||
setShowPivot(!showPivot);
|
|
||||||
}, [showPivot])}
|
|
||||||
>
|
|
||||||
<StyledCollapseButton collapse={showPivot}>
|
|
||||||
<ArrowDownSmallIcon />
|
|
||||||
</StyledCollapseButton>
|
|
||||||
<PivotsIcon />
|
|
||||||
{t('Pivots')}
|
|
||||||
</StyledListItem>
|
|
||||||
|
|
||||||
<MuiCollapse in={showPivot} style={{ paddingLeft: '16px' }}>
|
|
||||||
{isPivotEmpty ? (
|
|
||||||
<EmptyItem />
|
|
||||||
) : (
|
|
||||||
<PivotInternal
|
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
|
||||||
openPage={openPage}
|
|
||||||
allMetas={metas}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</MuiCollapse>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Pivots;
|
|
||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
useGuideHidden,
|
useGuideHidden,
|
||||||
useGuideHiddenUntilNextUpdate,
|
useGuideHiddenUntilNextUpdate,
|
||||||
} from '../../../../hooks/affine/use-is-first-load';
|
} from '../../../../hooks/affine/use-is-first-load';
|
||||||
import { StyledChangeLog, StyledChangeLogWarper } from '../shared-styles';
|
import { StyledChangeLog, StyledChangeLogWrapper } from '../shared-styles';
|
||||||
import { StyledLink } from '../style';
|
import { StyledLink } from '../style';
|
||||||
|
|
||||||
export const ChangeLog = () => {
|
export const ChangeLog = () => {
|
||||||
const [guideHidden, setGuideHidden] = useGuideHidden();
|
const [guideHidden, setGuideHidden] = useGuideHidden();
|
||||||
const [guideHiddenUntilNextUpdate, setGuideHiddenUntilNextUpdate] =
|
const [guideHiddenUntilNextUpdate, setGuideHiddenUntilNextUpdate] =
|
||||||
@@ -33,7 +34,7 @@ export const ChangeLog = () => {
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<StyledChangeLogWarper isClose={isClose}>
|
<StyledChangeLogWrapper isClose={isClose}>
|
||||||
<StyledChangeLog data-testid="change-log" isClose={isClose}>
|
<StyledChangeLog data-testid="change-log" isClose={isClose}>
|
||||||
<StyledLink href={'https://affine.pro'} target="_blank">
|
<StyledLink href={'https://affine.pro'} target="_blank">
|
||||||
<NewIcon />
|
<NewIcon />
|
||||||
@@ -49,7 +50,7 @@ export const ChangeLog = () => {
|
|||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</StyledChangeLog>
|
</StyledChangeLog>
|
||||||
</StyledChangeLogWarper>
|
</StyledChangeLogWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type { AllWorkspace } from '../../../shared';
|
|||||||
import { SidebarSwitch } from '../../affine/sidebar-switch';
|
import { SidebarSwitch } from '../../affine/sidebar-switch';
|
||||||
import { ChangeLog } from './changeLog';
|
import { ChangeLog } from './changeLog';
|
||||||
import Favorite from './favorite';
|
import Favorite from './favorite';
|
||||||
import { Pivots } from './Pivots';
|
import { Pinboard } from './Pinboard';
|
||||||
import { StyledListItem } from './shared-styles';
|
import { StyledListItem } from './shared-styles';
|
||||||
import {
|
import {
|
||||||
StyledLink,
|
StyledLink,
|
||||||
@@ -188,7 +188,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
|
|||||||
currentWorkspace={currentWorkspace}
|
currentWorkspace={currentWorkspace}
|
||||||
/>
|
/>
|
||||||
{!!blockSuiteWorkspace && (
|
{!!blockSuiteWorkspace && (
|
||||||
<Pivots
|
<Pinboard
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||||
openPage={openPage}
|
openPage={openPage}
|
||||||
allMetas={pageMeta}
|
allMetas={pageMeta}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const StyledCollapseButton = styled('button')<{
|
|||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
color: theme.colors.iconColor,
|
color: theme.colors.iconColor,
|
||||||
opacity: '.6',
|
opacity: '.6',
|
||||||
display: show ? 'block' : 'none',
|
display: show ? 'flex' : 'none',
|
||||||
svg: {
|
svg: {
|
||||||
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
|
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@ export const StyledChangeLog = styled('div')<{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
export const StyledChangeLogWarper = styled('div')<{
|
export const StyledChangeLogWrapper = styled('div')<{
|
||||||
isClose?: boolean;
|
isClose?: boolean;
|
||||||
}>(({ isClose }) => {
|
}>(({ isClose }) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
79
apps/web/src/hooks/affine/use-pinboard-data.ts
Normal file
79
apps/web/src/hooks/affine/use-pinboard-data.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Node } from '@affine/component';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { BlockSuiteWorkspace } from '../../shared';
|
||||||
|
|
||||||
|
export type RenderProps = {
|
||||||
|
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||||
|
onClick?: (e: MouseEvent<HTMLDivElement>, node: PinboardNode) => void;
|
||||||
|
showOperationButton?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeRenderProps = RenderProps & {
|
||||||
|
metas: PageMeta[];
|
||||||
|
currentMeta: PageMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PinboardNode = Node<NodeRenderProps>;
|
||||||
|
|
||||||
|
function flattenToTree(
|
||||||
|
metas: PageMeta[],
|
||||||
|
pinboardRender: PinboardNode['render'],
|
||||||
|
renderProps: RenderProps
|
||||||
|
): PinboardNode[] {
|
||||||
|
const rootMeta = metas.find(meta => meta.isRootPinboard);
|
||||||
|
const helper = (internalMetas: PageMeta[]): PinboardNode[] => {
|
||||||
|
return internalMetas.reduce<PinboardNode[]>(
|
||||||
|
(returnedMetas, internalMeta) => {
|
||||||
|
const { subpageIds = [] } = internalMeta;
|
||||||
|
const childrenMetas = subpageIds
|
||||||
|
.map(id => metas.find(m => m.id === id)!)
|
||||||
|
.filter(m => m);
|
||||||
|
// @ts-ignore
|
||||||
|
const returnedMeta: PinboardNode = {
|
||||||
|
...internalMeta,
|
||||||
|
children: helper(childrenMetas),
|
||||||
|
render: (node, props) =>
|
||||||
|
pinboardRender(node, props, {
|
||||||
|
...renderProps,
|
||||||
|
currentMeta: internalMeta,
|
||||||
|
metas,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
returnedMetas.push(returnedMeta);
|
||||||
|
return returnedMetas;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return helper(rootMeta ? [{ ...rootMeta, renderTopLine: false }] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePinboardData({
|
||||||
|
metas,
|
||||||
|
pinboardRender,
|
||||||
|
blockSuiteWorkspace,
|
||||||
|
onClick,
|
||||||
|
showOperationButton,
|
||||||
|
}: {
|
||||||
|
metas: PageMeta[];
|
||||||
|
pinboardRender: PinboardNode['render'];
|
||||||
|
} & RenderProps) {
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
flattenToTree(metas, pinboardRender, {
|
||||||
|
blockSuiteWorkspace,
|
||||||
|
onClick,
|
||||||
|
showOperationButton,
|
||||||
|
}),
|
||||||
|
[blockSuiteWorkspace, metas, onClick, pinboardRender, showOperationButton]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePinboardData;
|
||||||
@@ -4,21 +4,21 @@ import type { PageMeta } from '@blocksuite/store';
|
|||||||
import { nanoid } from '@blocksuite/store';
|
import { nanoid } from '@blocksuite/store';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper';
|
import type { BlockSuiteWorkspace } from '../../shared';
|
||||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
import { useBlockSuiteWorkspaceHelper } from '../use-blocksuite-workspace-helper';
|
||||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
import { usePageMetaHelper } from '../use-page-meta';
|
||||||
import type { NodeRenderProps, TreeNode } from '../types';
|
import type { NodeRenderProps, PinboardNode } from './use-pinboard-data';
|
||||||
|
|
||||||
const logger = new DebugLogger('pivot');
|
const logger = new DebugLogger('pinboard');
|
||||||
|
|
||||||
const findRootIds = (metas: PageMeta[], id: string): string[] => {
|
function findRootIds(metas: PageMeta[], id: string): string[] {
|
||||||
const parentMeta = metas.find(m => m.subpageIds?.includes(id));
|
const parentMeta = metas.find(m => m.subpageIds?.includes(id));
|
||||||
if (!parentMeta) {
|
if (!parentMeta) {
|
||||||
return [id];
|
return [id];
|
||||||
}
|
}
|
||||||
return [parentMeta.id, ...findRootIds(metas, parentMeta.id)];
|
return [parentMeta.id, ...findRootIds(metas, parentMeta.id)];
|
||||||
};
|
}
|
||||||
export const usePivotHandler = ({
|
export function usePinboardHandler({
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
metas,
|
metas,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -30,13 +30,12 @@ export const usePivotHandler = ({
|
|||||||
onAdd?: (addedId: string, parentId: string) => void;
|
onAdd?: (addedId: string, parentId: string) => void;
|
||||||
onDelete?: TreeViewProps<NodeRenderProps>['onDelete'];
|
onDelete?: TreeViewProps<NodeRenderProps>['onDelete'];
|
||||||
onDrop?: TreeViewProps<NodeRenderProps>['onDrop'];
|
onDrop?: TreeViewProps<NodeRenderProps>['onDrop'];
|
||||||
}) => {
|
}) {
|
||||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||||
const { getPageMeta, setPageMeta, shiftPageMeta } =
|
const { getPageMeta, setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||||
usePageMetaHelper(blockSuiteWorkspace);
|
|
||||||
|
|
||||||
const handleAdd = useCallback(
|
const handleAdd = useCallback(
|
||||||
(node: TreeNode) => {
|
(node: PinboardNode) => {
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
createPage(id, node.id);
|
createPage(id, node.id);
|
||||||
onAdd?.(id, node.id);
|
onAdd?.(id, node.id);
|
||||||
@@ -45,7 +44,7 @@ export const usePivotHandler = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
(node: TreeNode) => {
|
(node: PinboardNode) => {
|
||||||
const removeToTrash = (currentMeta: PageMeta) => {
|
const removeToTrash = (currentMeta: PageMeta) => {
|
||||||
const { subpageIds = [] } = currentMeta;
|
const { subpageIds = [] } = currentMeta;
|
||||||
setPageMeta(currentMeta.id, {
|
setPageMeta(currentMeta.id, {
|
||||||
@@ -80,91 +79,57 @@ export const usePivotHandler = ({
|
|||||||
if (dropRootIds.includes(dragId)) {
|
if (dropRootIds.includes(dragId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { topLine, bottomLine } = position;
|
|
||||||
logger.info('handleDrop', {
|
logger.info('handleDrop', {
|
||||||
dragId,
|
dragId,
|
||||||
dropId,
|
dropId,
|
||||||
bottomLine,
|
position,
|
||||||
metas,
|
metas,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { topLine, bottomLine } = position;
|
||||||
|
|
||||||
const dragParentMeta = metas.find(meta =>
|
const dragParentMeta = metas.find(meta =>
|
||||||
meta.subpageIds?.includes(dragId)
|
meta.subpageIds?.includes(dragId)
|
||||||
);
|
);
|
||||||
if (bottomLine || topLine) {
|
if (bottomLine || topLine) {
|
||||||
const insertOffset = bottomLine ? 1 : 0;
|
const insertOffset = bottomLine ? 1 : 0;
|
||||||
|
|
||||||
const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId));
|
const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId));
|
||||||
|
if (dropParentMeta?.id === dragParentMeta?.id) {
|
||||||
if (!dropParentMeta) {
|
// same parent
|
||||||
// drop into root
|
const newSubpageIds = [...(dragParentMeta?.subpageIds ?? [])];
|
||||||
logger.info('drop into root and resort');
|
|
||||||
|
|
||||||
if (dragParentMeta) {
|
|
||||||
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
|
|
||||||
|
|
||||||
const deleteIndex = dragParentMeta.subpageIds?.findIndex(
|
|
||||||
id => id === dragId
|
|
||||||
);
|
|
||||||
newSubpageIds.splice(deleteIndex, 1);
|
|
||||||
setPageMeta(dragParentMeta.id, {
|
|
||||||
subpageIds: newSubpageIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('resort root meta');
|
|
||||||
const insertIndex =
|
|
||||||
metas.findIndex(m => m.id === dropId) + insertOffset;
|
|
||||||
shiftPageMeta(dragId, insertIndex);
|
|
||||||
return onDrop?.(dragId, dropId, position);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
dragParentMeta &&
|
|
||||||
(dragParentMeta.id === dropId ||
|
|
||||||
dragParentMeta.id === dropParentMeta!.id)
|
|
||||||
) {
|
|
||||||
logger.info('drop to resort');
|
|
||||||
// need to resort
|
|
||||||
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
|
|
||||||
|
|
||||||
const deleteIndex = newSubpageIds.findIndex(id => id === dragId);
|
const deleteIndex = newSubpageIds.findIndex(id => id === dragId);
|
||||||
newSubpageIds.splice(deleteIndex, 1);
|
newSubpageIds.splice(deleteIndex, 1);
|
||||||
|
|
||||||
const insertIndex =
|
const insertIndex =
|
||||||
newSubpageIds.findIndex(id => id === dropId) + insertOffset;
|
newSubpageIds.findIndex(id => id === dropId) + insertOffset;
|
||||||
newSubpageIds.splice(insertIndex, 0, dragId);
|
newSubpageIds.splice(insertIndex, 0, dragId);
|
||||||
setPageMeta(dropParentMeta.id, {
|
dragParentMeta &&
|
||||||
subpageIds: newSubpageIds,
|
setPageMeta(dragParentMeta.id, {
|
||||||
});
|
subpageIds: newSubpageIds,
|
||||||
|
});
|
||||||
return onDrop?.(dragId, dropId, position);
|
return onDrop?.(dragId, dropId, position);
|
||||||
}
|
}
|
||||||
|
const newDragParentSubpageIds = [...(dragParentMeta?.subpageIds ?? [])];
|
||||||
|
const deleteIndex = newDragParentSubpageIds.findIndex(
|
||||||
|
id => id === dragId
|
||||||
|
);
|
||||||
|
newDragParentSubpageIds.splice(deleteIndex, 1);
|
||||||
|
|
||||||
logger.info('drop into drop node parent and resort');
|
const newDropParentSubpageIds = [...(dropParentMeta?.subpageIds ?? [])];
|
||||||
|
const insertIndex =
|
||||||
if (dragParentMeta) {
|
newDropParentSubpageIds.findIndex(id => id === dropId) + insertOffset;
|
||||||
const metaIndex = dragParentMeta.subpageIds.findIndex(
|
newDropParentSubpageIds.splice(insertIndex, 0, dragId);
|
||||||
id => id === dragId
|
dragParentMeta &&
|
||||||
);
|
|
||||||
const newSubpageIds = [...dragParentMeta.subpageIds];
|
|
||||||
newSubpageIds.splice(metaIndex, 1);
|
|
||||||
setPageMeta(dragParentMeta.id, {
|
setPageMeta(dragParentMeta.id, {
|
||||||
subpageIds: newSubpageIds,
|
subpageIds: newDragParentSubpageIds,
|
||||||
|
});
|
||||||
|
dropParentMeta &&
|
||||||
|
setPageMeta(dropParentMeta.id, {
|
||||||
|
subpageIds: newDropParentSubpageIds,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])];
|
|
||||||
const insertIndex = newSubpageIds.findIndex(id => id === dropId) + 1;
|
|
||||||
newSubpageIds.splice(insertIndex, 0, dragId);
|
|
||||||
setPageMeta(dropParentMeta.id, {
|
|
||||||
subpageIds: newSubpageIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return onDrop?.(dragId, dropId, position);
|
return onDrop?.(dragId, dropId, position);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('drop into the drop node');
|
|
||||||
|
|
||||||
// drop into the node
|
// drop into the node
|
||||||
if (dragParentMeta && dragParentMeta.id === dropId) {
|
if (dragParentMeta && dragParentMeta.id === dropId) {
|
||||||
return;
|
return;
|
||||||
@@ -185,7 +150,7 @@ export const usePivotHandler = ({
|
|||||||
subpageIds: newSubpageIds,
|
subpageIds: newSubpageIds,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[metas, onDrop, setPageMeta, shiftPageMeta]
|
[metas, onDrop, setPageMeta]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -193,6 +158,6 @@ export const usePivotHandler = ({
|
|||||||
handleAdd,
|
handleAdd,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export default usePivotHandler;
|
export default usePinboardHandler;
|
||||||
@@ -13,8 +13,7 @@ declare module '@blocksuite/store' {
|
|||||||
trashDate?: number;
|
trashDate?: number;
|
||||||
// whether to create the page with the default template
|
// whether to create the page with the default template
|
||||||
init?: boolean;
|
init?: boolean;
|
||||||
// use for subpage
|
isRootPinboard?: boolean;
|
||||||
isPivots?: boolean;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +43,13 @@ export function usePageMeta(
|
|||||||
return pageMeta;
|
return pageMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePageMetaHelper(blockSuiteWorkspace: BlockSuiteWorkspace) {
|
export function usePageMetaHelper(
|
||||||
|
blockSuiteWorkspace: BlockSuiteWorkspace | null
|
||||||
|
) {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
setPageTitle: (pageId: string, newTitle: string) => {
|
setPageTitle: (pageId: string, newTitle: string) => {
|
||||||
|
assertExists(blockSuiteWorkspace);
|
||||||
const page = blockSuiteWorkspace.getPage(pageId);
|
const page = blockSuiteWorkspace.getPage(pageId);
|
||||||
assertExists(page);
|
assertExists(page);
|
||||||
const pageBlock = page
|
const pageBlock = page
|
||||||
@@ -58,15 +60,19 @@ export function usePageMetaHelper(blockSuiteWorkspace: BlockSuiteWorkspace) {
|
|||||||
pageBlock.title.delete(0, pageBlock.title.length);
|
pageBlock.title.delete(0, pageBlock.title.length);
|
||||||
pageBlock.title.insert(newTitle, 0);
|
pageBlock.title.insert(newTitle, 0);
|
||||||
});
|
});
|
||||||
|
assertExists(blockSuiteWorkspace);
|
||||||
blockSuiteWorkspace.meta.setPageMeta(pageId, { title: newTitle });
|
blockSuiteWorkspace.meta.setPageMeta(pageId, { title: newTitle });
|
||||||
},
|
},
|
||||||
setPageMeta: (pageId: string, pageMeta: Partial<PageMeta>) => {
|
setPageMeta: (pageId: string, pageMeta: Partial<PageMeta>) => {
|
||||||
|
assertExists(blockSuiteWorkspace);
|
||||||
blockSuiteWorkspace.meta.setPageMeta(pageId, pageMeta);
|
blockSuiteWorkspace.meta.setPageMeta(pageId, pageMeta);
|
||||||
},
|
},
|
||||||
getPageMeta: (pageId: string) => {
|
getPageMeta: (pageId: string) => {
|
||||||
|
assertExists(blockSuiteWorkspace);
|
||||||
return blockSuiteWorkspace.meta.getPageMeta(pageId);
|
return blockSuiteWorkspace.meta.getPageMeta(pageId);
|
||||||
},
|
},
|
||||||
shiftPageMeta: (pageId: string, index: number) => {
|
shiftPageMeta: (pageId: string, index: number) => {
|
||||||
|
assertExists(blockSuiteWorkspace);
|
||||||
return blockSuiteWorkspace.meta.shiftPageMeta(pageId, index);
|
return blockSuiteWorkspace.meta.shiftPageMeta(pageId, index);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useSyncRouterWithCurrentWorkspace } from '../../../hooks/use-sync-route
|
|||||||
import { WorkspaceLayout } from '../../../layouts';
|
import { WorkspaceLayout } from '../../../layouts';
|
||||||
import { WorkspacePlugins } from '../../../plugins';
|
import { WorkspacePlugins } from '../../../plugins';
|
||||||
import type { NextPageWithLayout } from '../../../shared';
|
import type { NextPageWithLayout } from '../../../shared';
|
||||||
|
import { ensureRootPinboard } from '../../../utils';
|
||||||
|
|
||||||
const AllPage: NextPageWithLayout = () => {
|
const AllPage: NextPageWithLayout = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,6 +36,8 @@ const AllPage: NextPageWithLayout = () => {
|
|||||||
}
|
}
|
||||||
if (currentWorkspace.flavour !== WorkspaceFlavour.LOCAL) {
|
if (currentWorkspace.flavour !== WorkspaceFlavour.LOCAL) {
|
||||||
// only create a new page for local workspace
|
// only create a new page for local workspace
|
||||||
|
// just ensure the root pinboard exists
|
||||||
|
ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const localProvider = currentWorkspace.providers.find(
|
const localProvider = currentWorkspace.providers.find(
|
||||||
@@ -53,6 +56,8 @@ const AllPage: NextPageWithLayout = () => {
|
|||||||
});
|
});
|
||||||
jumpToPage(currentWorkspace.id, pageId);
|
jumpToPage(currentWorkspace.id, pageId);
|
||||||
}
|
}
|
||||||
|
// no matter workspace is empty, ensure the root pinboard exists
|
||||||
|
ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
|
||||||
};
|
};
|
||||||
provider.callbacks.add(callback);
|
provider.callbacks.add(callback);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import markdown from '@affine/templates/Welcome-to-AFFiNE.md';
|
import markdown from '@affine/templates/Welcome-to-AFFiNE.md';
|
||||||
import { ContentParser } from '@blocksuite/blocks/content-parser';
|
import { ContentParser } from '@blocksuite/blocks/content-parser';
|
||||||
import type { EditorContainer } from '@blocksuite/editor';
|
|
||||||
import type { Page } from '@blocksuite/store';
|
import type { Page } from '@blocksuite/store';
|
||||||
|
import { nanoid } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import type { BlockSuiteWorkspace } from '../shared';
|
||||||
|
|
||||||
const demoTitle = markdown
|
const demoTitle = markdown
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -15,7 +17,7 @@ const demoText = markdown.split('\n').slice(1).join('\n');
|
|||||||
|
|
||||||
const logger = new DebugLogger('init-page');
|
const logger = new DebugLogger('init-page');
|
||||||
|
|
||||||
export function initPage(page: Page, editor: Readonly<EditorContainer>): void {
|
export function initPage(page: Page): void {
|
||||||
logger.debug('initEmptyPage', page.id);
|
logger.debug('initEmptyPage', page.id);
|
||||||
// Add page block and surface block at root level
|
// Add page block and surface block at root level
|
||||||
const isFirstPage = page.meta.init === true;
|
const isFirstPage = page.meta.init === true;
|
||||||
@@ -23,26 +25,23 @@ export function initPage(page: Page, editor: Readonly<EditorContainer>): void {
|
|||||||
page.workspace.setPageMeta(page.id, {
|
page.workspace.setPageMeta(page.id, {
|
||||||
init: false,
|
init: false,
|
||||||
});
|
});
|
||||||
_initPageWithDemoMarkdown(page, editor);
|
_initPageWithDemoMarkdown(page);
|
||||||
} else {
|
} else {
|
||||||
_initEmptyPage(page, editor);
|
_initEmptyPage(page);
|
||||||
}
|
}
|
||||||
page.resetHistory();
|
page.resetHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _initEmptyPage(page: Page, _: Readonly<EditorContainer>) {
|
export function _initEmptyPage(page: Page, title?: string): void {
|
||||||
const pageBlockId = page.addBlock('affine:page', {
|
const pageBlockId = page.addBlock('affine:page', {
|
||||||
title: new page.Text(''),
|
title: new page.Text(title ?? ''),
|
||||||
});
|
});
|
||||||
page.addBlock('affine:surface', {}, null);
|
page.addBlock('affine:surface', {}, null);
|
||||||
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
|
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
|
||||||
page.addBlock('affine:paragraph', {}, frameId);
|
page.addBlock('affine:paragraph', {}, frameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _initPageWithDemoMarkdown(
|
export function _initPageWithDemoMarkdown(page: Page): void {
|
||||||
page: Page,
|
|
||||||
editor: Readonly<EditorContainer>
|
|
||||||
): void {
|
|
||||||
logger.debug('initPageWithDefaultMarkdown', page.id);
|
logger.debug('initPageWithDefaultMarkdown', page.id);
|
||||||
const pageBlockId = page.addBlock('affine:page', {
|
const pageBlockId = page.addBlock('affine:page', {
|
||||||
title: new page.Text(demoTitle),
|
title: new page.Text(demoTitle),
|
||||||
@@ -63,3 +62,25 @@ export function _initPageWithDemoMarkdown(
|
|||||||
});
|
});
|
||||||
page.workspace.setPageMeta(page.id, { demoTitle });
|
page.workspace.setPageMeta(page.id, { demoTitle });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ensureRootPinboard(blockSuiteWorkspace: BlockSuiteWorkspace) {
|
||||||
|
const metas = blockSuiteWorkspace.meta.pageMetas;
|
||||||
|
const rootMeta = metas.find(m => m.isRootPinboard);
|
||||||
|
|
||||||
|
if (rootMeta) {
|
||||||
|
return rootMeta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPinboardPage = blockSuiteWorkspace.createPage(nanoid());
|
||||||
|
|
||||||
|
const title = `${blockSuiteWorkspace.meta.name}'s Pinboard`;
|
||||||
|
|
||||||
|
_initEmptyPage(rootPinboardPage, title);
|
||||||
|
|
||||||
|
blockSuiteWorkspace.meta.setPageMeta(rootPinboardPage.id, {
|
||||||
|
isRootPinboard: true,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootPinboardPage.id;
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,11 +144,12 @@ export const TreeNode = <RenderProps,>({
|
|||||||
}: TreeNodeProps<RenderProps>) => {
|
}: TreeNodeProps<RenderProps>) => {
|
||||||
const { indent, enableDnd, collapsedIds } = otherProps;
|
const { indent, enableDnd, collapsedIds } = otherProps;
|
||||||
const collapsed = collapsedIds.includes(node.id);
|
const collapsed = collapsedIds.includes(node.id);
|
||||||
|
const { renderTopLine = true, renderBottomLine = true } = node;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledTreeNodeContainer ref={dragRef} isDragging={isDragging}>
|
<StyledTreeNodeContainer ref={dragRef} isDragging={isDragging}>
|
||||||
<StyledTreeNodeWrapper>
|
<StyledTreeNodeWrapper>
|
||||||
{enableDnd && index === 0 && (
|
{enableDnd && renderTopLine && index === 0 && (
|
||||||
<NodeLine
|
<NodeLine
|
||||||
node={node}
|
node={node}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
@@ -174,13 +175,15 @@ export const TreeNode = <RenderProps,>({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{enableDnd && (!node.children?.length || collapsed) && (
|
{enableDnd &&
|
||||||
<NodeLine
|
renderBottomLine &&
|
||||||
node={node}
|
(!node.children?.length || collapsed) && (
|
||||||
{...otherProps}
|
<NodeLine
|
||||||
allowDrop={!isDragging && allowDrop}
|
node={node}
|
||||||
/>
|
{...otherProps}
|
||||||
)}
|
allowDrop={!isDragging && allowDrop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</StyledTreeNodeWrapper>
|
</StyledTreeNodeWrapper>
|
||||||
<StyledCollapse in={!collapsed} indent={indent}>
|
<StyledCollapse in={!collapsed} indent={indent}>
|
||||||
{node.children &&
|
{node.children &&
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type Node<RenderProps = unknown> = {
|
|||||||
},
|
},
|
||||||
renderProps?: RenderProps
|
renderProps?: RenderProps
|
||||||
) => ReactNode;
|
) => ReactNode;
|
||||||
|
renderTopLine?: boolean;
|
||||||
|
renderBottomLine?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CommonProps<RenderProps = unknown> = {
|
type CommonProps<RenderProps = unknown> = {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
@@ -123,3 +124,9 @@ export async function loginUser(
|
|||||||
export async function openHomePage(page: Page) {
|
export async function openHomePage(page: Page) {
|
||||||
return page.goto('http://localhost:8080');
|
return page.goto('http://localhost:8080');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMetas(page: Page): Promise<PageMeta[]> {
|
||||||
|
return page.evaluate(
|
||||||
|
() => globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { expect } from '@playwright/test';
|
|||||||
import { openHomePage } from '../libs/load-page';
|
import { openHomePage } from '../libs/load-page';
|
||||||
import { clickPageMoreActions, newPage } from '../libs/page-logic';
|
import { clickPageMoreActions, newPage } from '../libs/page-logic';
|
||||||
import { test } from '../libs/playwright';
|
import { test } from '../libs/playwright';
|
||||||
|
import { getMetas } from '../libs/utils';
|
||||||
|
|
||||||
async function createRootPage(page: Page, title: string) {
|
async function createPinboardPage(page: Page, parentId: string, title: string) {
|
||||||
await newPage(page);
|
await newPage(page);
|
||||||
await page.focus('.affine-default-page-block-title');
|
await page.focus('.affine-default-page-block-title');
|
||||||
await page.type('.affine-default-page-block-title', title, {
|
await page.type('.affine-default-page-block-title', title, {
|
||||||
@@ -13,73 +14,249 @@ async function createRootPage(page: Page, title: string) {
|
|||||||
});
|
});
|
||||||
await clickPageMoreActions(page);
|
await clickPageMoreActions(page);
|
||||||
await page.getByTestId('move-to-menu-item').click();
|
await page.getByTestId('move-to-menu-item').click();
|
||||||
await page.getByTestId('root-pivot-button-in-pivots-menu').click();
|
await page
|
||||||
|
.getByTestId('pinboard-menu')
|
||||||
|
.getByTestId(`pinboard-${parentId}`)
|
||||||
|
.click();
|
||||||
}
|
}
|
||||||
async function createPivotPage(page: Page, title: string, parentTitle: string) {
|
|
||||||
await newPage(page);
|
async function initHomePageWithPinboard(page: 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('pivots-menu').getByText(parentTitle).click();
|
|
||||||
}
|
|
||||||
export async function initPinBoard(page: Page) {
|
|
||||||
await openHomePage(page);
|
await openHomePage(page);
|
||||||
await createRootPage(page, 'parent1');
|
await page.waitForSelector('[data-testid="sidebar-pinboard-container"]');
|
||||||
await createRootPage(page, 'parent2');
|
return (await getMetas(page)).find(m => m.isRootPinboard);
|
||||||
await createPivotPage(page, 'child1', 'parent1');
|
|
||||||
await createPivotPage(page, 'child2', 'parent1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openPinboardPageOperationMenu(page: Page, id: string) {
|
||||||
|
const node = await page
|
||||||
|
.getByTestId('sidebar-pinboard-container')
|
||||||
|
.getByTestId(`pinboard-${id}`);
|
||||||
|
await node.hover();
|
||||||
|
await node.getByTestId('pinboard-operation-button').click();
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('PinBoard interaction', () => {
|
test.describe('PinBoard interaction', () => {
|
||||||
test('Add pivot', async ({ page }) => {
|
test('Have initial root pinboard page when first in', async ({ page }) => {
|
||||||
await initPinBoard(page);
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
expect(rootPinboardMeta).not.toBeUndefined();
|
||||||
});
|
});
|
||||||
test('Remove pivots', async ({ page }) => {
|
|
||||||
await initPinBoard(page);
|
|
||||||
|
|
||||||
const child2Meta = await page.evaluate(() => {
|
test('Root pinboard page have no operation of "trash" ,"rename" and "move to"', async ({
|
||||||
return globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas.find(
|
page,
|
||||||
m => m.title === 'child2'
|
}) => {
|
||||||
);
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
});
|
await openPinboardPageOperationMenu(page, rootPinboardMeta?.id ?? '');
|
||||||
|
|
||||||
const child2 = await page
|
|
||||||
.getByTestId('sidebar-pivots-container')
|
|
||||||
.getByTestId(`pivot-${child2Meta?.id}`);
|
|
||||||
await child2.hover();
|
|
||||||
await child2.getByTestId('pivot-operation-button').click();
|
|
||||||
await page.getByTestId('pivot-operation-move-to').click();
|
|
||||||
await page.getByTestId('remove-from-pivots-button').click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
expect(
|
expect(
|
||||||
await page.locator(`[data-testid="pivot-${child2Meta.id}"]`).count()
|
await page
|
||||||
|
.locator(`[data-testid="pinboard-operation-move-to-trash"]`)
|
||||||
|
.count()
|
||||||
|
).toEqual(0);
|
||||||
|
expect(
|
||||||
|
await page.locator(`[data-testid="pinboard-operation-rename"]`).count()
|
||||||
|
).toEqual(0);
|
||||||
|
expect(
|
||||||
|
await page.locator(`[data-testid="pinboard-operation-move-to"]`).count()
|
||||||
).toEqual(0);
|
).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search pivot', async ({ page }) => {
|
test('Click Pinboard in sidebar should open root pinboard page', async ({
|
||||||
await initPinBoard(page);
|
page,
|
||||||
|
}) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await page.getByTestId(`pinboard-${rootPinboardMeta?.id}`).click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
expect(await page.locator('affine-editor-container')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
const child2Meta = await page.evaluate(() => {
|
test('Add pinboard by header operation menu', async ({ page }) => {
|
||||||
return globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas.find(
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
m => m.title === 'child2'
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
);
|
const meta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
});
|
expect(meta).not.toBeUndefined();
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId('[data-testid="sidebar-pinboard-container"]')
|
||||||
|
.getByTestId(`pinboard-${meta?.id}`)
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
const child2 = await page
|
test('Add pinboard by sidebar operation menu', async ({ page }) => {
|
||||||
.getByTestId('sidebar-pivots-container')
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
.getByTestId(`pivot-${child2Meta?.id}`);
|
|
||||||
await child2.hover();
|
|
||||||
await child2.getByTestId('pivot-operation-button').click();
|
|
||||||
await page.getByTestId('pivot-operation-move-to').click();
|
|
||||||
|
|
||||||
await page.fill('[data-testid="pivots-menu-search"]', '111');
|
await openPinboardPageOperationMenu(page, rootPinboardMeta?.id ?? '');
|
||||||
|
await page.getByTestId('pinboard-operation-add').click();
|
||||||
|
const newPageMeta = (await getMetas(page)).find(
|
||||||
|
m => m.id !== rootPinboardMeta?.id
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId('sidebar-pinboard-container')
|
||||||
|
.getByTestId(`pinboard-${newPageMeta?.id}`)
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Move pinboard to another in sidebar', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2');
|
||||||
|
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
|
||||||
|
await page.getByTestId('pinboard-operation-move-to').click();
|
||||||
|
await page
|
||||||
|
.getByTestId('pinboard-menu')
|
||||||
|
.getByTestId(`pinboard-${childMeta2?.id}`)
|
||||||
|
.click();
|
||||||
|
expect(
|
||||||
|
(await getMetas(page)).find(m => m.title === 'test2')?.subpageIds.length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should no show pinboard self in move to menu', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2');
|
||||||
|
|
||||||
|
await page.getByTestId('all-pages').click();
|
||||||
|
await page
|
||||||
|
.getByTestId(`page-list-item-${childMeta?.id}`)
|
||||||
|
.getByTestId('page-list-operation-button')
|
||||||
|
.click();
|
||||||
|
await page.getByTestId('move-to-menu-item').click();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId('pinboard-menu')
|
||||||
|
.locator(`[data-testid="pinboard-${childMeta?.id}"]`)
|
||||||
|
.count()
|
||||||
|
).toEqual(0);
|
||||||
|
});
|
||||||
|
test('Move pinboard to another in page list', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2');
|
||||||
|
|
||||||
|
await page.getByTestId('all-pages').click();
|
||||||
|
await page
|
||||||
|
.getByTestId(`page-list-item-${childMeta?.id}`)
|
||||||
|
.getByTestId('page-list-operation-button')
|
||||||
|
.click();
|
||||||
|
await page.getByTestId('move-to-menu-item').click();
|
||||||
|
await page
|
||||||
|
.getByTestId('pinboard-menu')
|
||||||
|
.getByTestId(`pinboard-${childMeta2?.id}`)
|
||||||
|
.click();
|
||||||
|
expect(
|
||||||
|
(await getMetas(page)).find(m => m.title === 'test2')?.subpageIds.length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove from pinboard', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
|
||||||
|
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
|
||||||
|
|
||||||
|
await page.getByTestId('pinboard-operation-move-to').click();
|
||||||
|
await page.getByTestId('remove-from-pinboard-button').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
expect(
|
||||||
|
await page.locator(`[data-testid="pinboard-${childMeta?.id}"]`).count()
|
||||||
|
).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search pinboard', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
|
||||||
|
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
|
||||||
|
|
||||||
|
await page.getByTestId('pinboard-operation-move-to').click();
|
||||||
|
|
||||||
|
await page.fill('[data-testid="pinboard-menu-search"]', '111');
|
||||||
expect(await page.locator('[alt="no result"]').count()).toEqual(1);
|
expect(await page.locator('[alt="no result"]').count()).toEqual(1);
|
||||||
|
|
||||||
await page.fill('[data-testid="pivots-menu-search"]', 'parent2');
|
await page.fill('[data-testid="pinboard-menu-search"]', 'test1');
|
||||||
expect(
|
expect(
|
||||||
await page.locator('[data-testid="pivot-search-result"]').count()
|
await page.locator('[data-testid="pinboard-search-result"]').count()
|
||||||
).toEqual(1);
|
).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Rename pinboard', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
|
||||||
|
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
|
||||||
|
|
||||||
|
await page.getByTestId('pinboard-operation-rename').click();
|
||||||
|
await page.fill(`[data-testid="pinboard-input-${childMeta?.id}"]`, 'test2');
|
||||||
|
|
||||||
|
const title = (await page
|
||||||
|
.locator('.affine-default-page-block-title')
|
||||||
|
.textContent()) as string;
|
||||||
|
|
||||||
|
expect(title).toEqual('test2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Move pinboard to trash', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
await createPinboardPage(page, childMeta?.id ?? '', 'test2');
|
||||||
|
const grandChildMeta = (await getMetas(page)).find(
|
||||||
|
m => m.title === 'test2'
|
||||||
|
);
|
||||||
|
|
||||||
|
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
|
||||||
|
|
||||||
|
await page.getByTestId('pinboard-operation-move-to-trash').click();
|
||||||
|
(
|
||||||
|
await page.waitForSelector('[data-testid="move-to-trash-confirm"]')
|
||||||
|
).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId('sidebar-pinboard-container')
|
||||||
|
.locator(`[data-testid="pinboard-${childMeta?.id}"]`)
|
||||||
|
.count()
|
||||||
|
).toEqual(0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByTestId('sidebar-pinboard-container')
|
||||||
|
.locator(`[data-testid="pinboard-${grandChildMeta?.id}"]`)
|
||||||
|
.count()
|
||||||
|
).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIXME
|
||||||
|
test.skip('Copy link', async ({ page }) => {
|
||||||
|
const rootPinboardMeta = await initHomePageWithPinboard(page);
|
||||||
|
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
|
||||||
|
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
|
||||||
|
|
||||||
|
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
|
||||||
|
|
||||||
|
await page.getByTestId('copy-link').click();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'paste-input';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
await page.keyboard.press(`Meta+v`, { delay: 50 });
|
||||||
|
await page.keyboard.press(`Control+v`, { delay: 50 });
|
||||||
|
const copiedValue = await page
|
||||||
|
.locator('#paste-input')
|
||||||
|
.evaluate((input: HTMLInputElement) => input.value);
|
||||||
|
expect(copiedValue).toEqual(page.url());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user