mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-13 14:04:34 +03:00
feat: support pivots menu (#1755)
This commit is contained in:
parent
4dd1490eef
commit
b6ded30770
@ -0,0 +1,24 @@
|
||||
import { MenuItem } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { CopyIcon } from '@blocksuite/icons';
|
||||
// import { useRouter } from "next/router";
|
||||
// import { useCallback } from "react";
|
||||
//
|
||||
// import { toast } from "../../../utils";
|
||||
|
||||
export const CopyLink = () => {
|
||||
const { t } = useTranslation();
|
||||
// const router = useRouter();
|
||||
// const copyUrl = useCallback(() => {
|
||||
// const workspaceId = router.query.workspaceId;
|
||||
// navigator.clipboard.writeText(window.location.href);
|
||||
// toast(t("Copied link to clipboard"));
|
||||
// }, [router.query.workspaceId, t]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={() => {}} icon={<CopyIcon />} disabled={true}>
|
||||
{t('Copy Link')}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,50 @@
|
||||
import { Menu, MenuItem } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import {
|
||||
ArrowRightSmallIcon,
|
||||
ExportIcon,
|
||||
ExportToHtmlIcon,
|
||||
ExportToMarkdownIcon,
|
||||
} from '@blocksuite/icons';
|
||||
|
||||
export const Export = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
width={248}
|
||||
placement="left-start"
|
||||
trigger="click"
|
||||
content={
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// @ts-expect-error
|
||||
globalThis.currentEditor.contentParser.onExportHtml();
|
||||
}}
|
||||
icon={<ExportToHtmlIcon />}
|
||||
>
|
||||
{t('Export to HTML')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// @ts-expect-error
|
||||
globalThis.currentEditor.contentParser.onExportMarkdown();
|
||||
}}
|
||||
icon={<ExportToMarkdownIcon />}
|
||||
>
|
||||
{t('Export to Markdown')}
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
icon={<ExportIcon />}
|
||||
endIcon={<ArrowRightSmallIcon />}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{t('Export')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
import { MenuItem } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { ArrowRightSmallIcon, MoveToIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { PivotsMenu } from '../pivots';
|
||||
|
||||
export const MoveTo = ({
|
||||
metas,
|
||||
currentMeta,
|
||||
blockSuiteWorkspace,
|
||||
}: {
|
||||
metas: PageMeta[];
|
||||
currentMeta: PageMeta;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const open = anchorEl !== null;
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
ref={ref}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(ref.current);
|
||||
}}
|
||||
icon={<MoveToIcon />}
|
||||
endIcon={<ArrowRightSmallIcon />}
|
||||
>
|
||||
{t('Move to')}
|
||||
</MenuItem>
|
||||
<PivotsMenu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
placement="left-start"
|
||||
metas={metas.filter(meta => !meta.trash)}
|
||||
currentMeta={currentMeta}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
import { Confirm, MenuItem } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { DeleteTemporarilyIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { usePageMetaHelper } from '../../../hooks/use-page-meta';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { toast } from '../../../utils';
|
||||
|
||||
export const MoveToTrash = ({
|
||||
currentMeta,
|
||||
blockSuiteWorkspace,
|
||||
testId,
|
||||
}: {
|
||||
currentMeta: PageMeta;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
testId?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
icon={<DeleteTemporarilyIcon />}
|
||||
>
|
||||
{t('Move to Trash')}
|
||||
</MenuItem>
|
||||
<Confirm
|
||||
title={t('Delete page?')}
|
||||
content={t('will be moved to Trash', {
|
||||
title: currentMeta.title || 'Untitled',
|
||||
})}
|
||||
confirmText={t('Delete')}
|
||||
confirmType="danger"
|
||||
open={open}
|
||||
onConfirm={() => {
|
||||
toast(t('Moved to Trash'));
|
||||
setOpen(false);
|
||||
setPageMeta(currentMeta.id, {
|
||||
trash: true,
|
||||
trashDate: +new Date(),
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,4 @@
|
||||
export * from './CopyLink';
|
||||
export * from './Export';
|
||||
export * from './MoveTo';
|
||||
export * from './MoveToTrash';
|
103
apps/web/src/components/affine/pivots/OperationButton.tsx
Normal file
103
apps/web/src/components/affine/pivots/OperationButton.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { MuiClickAwayListener } from '@affine/component';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { OperationMenu } from './OperationMenu';
|
||||
import { PivotsMenu } from './PivotsMenu/PivotsMenu';
|
||||
import { StyledOperationButton } from './styles';
|
||||
|
||||
export type OperationButtonProps = {
|
||||
onAdd: () => void;
|
||||
onDelete: () => void;
|
||||
metas: PageMeta[];
|
||||
currentMeta: PageMeta;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
isHover: boolean;
|
||||
onMenuClose?: () => void;
|
||||
};
|
||||
export const OperationButton = ({
|
||||
onAdd,
|
||||
onDelete,
|
||||
metas,
|
||||
currentMeta,
|
||||
blockSuiteWorkspace,
|
||||
isHover,
|
||||
onMenuClose,
|
||||
}: OperationButtonProps) => {
|
||||
const {
|
||||
zIndex: { modal: modalIndex },
|
||||
} = useTheme();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [operationOpen, setOperationOpen] = useState(false);
|
||||
const [pivotsMenuOpen, setPivotsMenuOpen] = useState(false);
|
||||
|
||||
const menuIndex = useMemo(() => modalIndex + 1, [modalIndex]);
|
||||
|
||||
return (
|
||||
<MuiClickAwayListener
|
||||
onClickAway={() => {
|
||||
setOperationOpen(false);
|
||||
setPivotsMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setOperationOpen(false);
|
||||
setPivotsMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<StyledOperationButton
|
||||
ref={ref => setAnchorEl(ref)}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setOperationOpen(!operationOpen);
|
||||
}}
|
||||
visible={isHover}
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
</StyledOperationButton>
|
||||
<OperationMenu
|
||||
anchorEl={anchorEl}
|
||||
open={operationOpen}
|
||||
placement="bottom-start"
|
||||
zIndex={menuIndex}
|
||||
onSelect={type => {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
onAdd();
|
||||
break;
|
||||
case 'move':
|
||||
setPivotsMenuOpen(true);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete();
|
||||
break;
|
||||
}
|
||||
setOperationOpen(false);
|
||||
onMenuClose?.();
|
||||
}}
|
||||
currentMeta={currentMeta}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
|
||||
<PivotsMenu
|
||||
anchorEl={anchorEl}
|
||||
open={pivotsMenuOpen}
|
||||
placement="bottom-start"
|
||||
zIndex={menuIndex}
|
||||
metas={metas}
|
||||
currentMeta={currentMeta}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
showRemovePivots={true}
|
||||
/>
|
||||
</div>
|
||||
</MuiClickAwayListener>
|
||||
);
|
||||
};
|
74
apps/web/src/components/affine/pivots/OperationMenu.tsx
Normal file
74
apps/web/src/components/affine/pivots/OperationMenu.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import type { PureMenuProps } from '@affine/component';
|
||||
import { MenuItem, PureMenu } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { MoveToIcon, PenIcon, PlusIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { CopyLink, MoveToTrash } from '../operation-menu-items';
|
||||
|
||||
export type OperationMenuProps = {
|
||||
onSelect: (type: OperationMenuItems['type']) => void;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
currentMeta: PageMeta;
|
||||
} & PureMenuProps;
|
||||
|
||||
export type OperationMenuItems = {
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
type: 'add' | 'move' | 'rename' | 'delete' | 'copy';
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const menuItems: OperationMenuItems[] = [
|
||||
{
|
||||
label: 'Add a subpage inside',
|
||||
icon: <PlusIcon />,
|
||||
type: 'add',
|
||||
},
|
||||
{
|
||||
label: 'Move to',
|
||||
icon: <MoveToIcon />,
|
||||
type: 'move',
|
||||
},
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: <PenIcon />,
|
||||
type: 'rename',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const OperationMenu = ({
|
||||
onSelect,
|
||||
blockSuiteWorkspace,
|
||||
currentMeta,
|
||||
...menuProps
|
||||
}: OperationMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PureMenu width={256} {...menuProps}>
|
||||
{menuItems.map((item, index) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onSelect(item.type);
|
||||
}}
|
||||
icon={item.icon}
|
||||
disabled={!!item.disabled}
|
||||
>
|
||||
{t(item.label)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
<MoveToTrash
|
||||
currentMeta={currentMeta}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
<CopyLink />
|
||||
</PureMenu>
|
||||
);
|
||||
};
|
65
apps/web/src/components/affine/pivots/PivotRender.tsx
Normal file
65
apps/web/src/components/affine/pivots/PivotRender.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
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 { OperationButton } from './OperationButton';
|
||||
import { StyledCollapsedButton, StyledPivot } from './styles';
|
||||
import type { TreeNode } from './types';
|
||||
|
||||
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 router = useRouter();
|
||||
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
|
||||
const active = router.query.pageId === node.id;
|
||||
|
||||
return (
|
||||
<StyledPivot
|
||||
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 />}
|
||||
<span>{currentMeta?.title || 'Untitled'}</span>
|
||||
{showOperationButton && (
|
||||
<OperationButton
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
metas={metas}
|
||||
currentMeta={currentMeta!}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace!}
|
||||
isHover={isHover}
|
||||
onMenuClose={() => setIsHover(false)}
|
||||
/>
|
||||
)}
|
||||
</StyledPivot>
|
||||
);
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
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;
|
85
apps/web/src/components/affine/pivots/PivotsMenu/Pivots.tsx
Normal file
85
apps/web/src/components/affine/pivots/PivotsMenu/Pivots.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
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 { usePivotData } from '../hooks/usePivotData';
|
||||
import { usePivotHandler } from '../hooks/usePivotHandler';
|
||||
import { PivotRender } from '../PivotRender';
|
||||
import { StyledCollapsedButton, StyledPivot } from '../styles';
|
||||
import EmptyItem from './EmptyItem';
|
||||
import type { PivotsMenuProps } from './PivotsMenu';
|
||||
|
||||
export const Pivots = ({
|
||||
metas,
|
||||
blockSuiteWorkspace,
|
||||
currentMeta,
|
||||
}: Pick<PivotsMenuProps, 'metas' | 'blockSuiteWorkspace' | 'currentMeta'>) => {
|
||||
const { t } = useTranslation();
|
||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||
const [showPivot, setShowPivot] = useState(true);
|
||||
const { handleDrop } = usePivotHandler({
|
||||
blockSuiteWorkspace,
|
||||
metas,
|
||||
});
|
||||
const { data } = usePivotData({
|
||||
metas,
|
||||
pivotRender: PivotRender,
|
||||
blockSuiteWorkspace,
|
||||
onClick: (e, node) => {
|
||||
handleDrop(currentMeta.id, node.id, {
|
||||
bottomLine: false,
|
||||
topLine: false,
|
||||
internal: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isPivotEmpty = useMemo(
|
||||
() => metas.filter(meta => !meta.trash).length === 0,
|
||||
[metas]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledPivot
|
||||
onClick={() => {
|
||||
setPageMeta(currentMeta.id, { isPivots: true });
|
||||
}}
|
||||
>
|
||||
<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;
|
109
apps/web/src/components/affine/pivots/PivotsMenu/PivotsMenu.tsx
Normal file
109
apps/web/src/components/affine/pivots/PivotsMenu/PivotsMenu.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import type { PureMenuProps } from '@affine/component';
|
||||
import { Input, PureMenu } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
||||
import {
|
||||
StyledMenuContent,
|
||||
StyledMenuFooter,
|
||||
StyledMenuSubTitle,
|
||||
StyledPivot,
|
||||
StyledSearchContainer,
|
||||
} from '../styles';
|
||||
import { Pivots } from './Pivots';
|
||||
|
||||
export type PivotsMenuProps = {
|
||||
metas: PageMeta[];
|
||||
currentMeta: PageMeta;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
showRemovePivots?: boolean;
|
||||
} & PureMenuProps;
|
||||
|
||||
export const PivotsMenu = ({
|
||||
metas,
|
||||
currentMeta,
|
||||
blockSuiteWorkspace,
|
||||
showRemovePivots = false,
|
||||
...pureMenuProps
|
||||
}: PivotsMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||
const [query, setQuery] = useState('');
|
||||
const isSearching = query.length > 0;
|
||||
|
||||
const searchResult = metas.filter(
|
||||
meta => !meta.trash && meta.title.includes(query)
|
||||
);
|
||||
|
||||
return (
|
||||
<PureMenu width={320} height={480} {...pureMenuProps}>
|
||||
<StyledSearchContainer>
|
||||
<label>
|
||||
<SearchIcon />
|
||||
</label>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('Move page to...')}
|
||||
height={32}
|
||||
noBorder={true}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</StyledSearchContainer>
|
||||
|
||||
<StyledMenuContent>
|
||||
{isSearching && (
|
||||
<>
|
||||
<StyledMenuSubTitle>
|
||||
{searchResult.length
|
||||
? t('Find results', { number: searchResult.length })
|
||||
: t('Find 0 result')}
|
||||
</StyledMenuSubTitle>
|
||||
{searchResult.map(meta => {
|
||||
return <StyledPivot key={meta.id}>{meta.title}</StyledPivot>;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isSearching && (
|
||||
<>
|
||||
<StyledMenuSubTitle>Suggested</StyledMenuSubTitle>
|
||||
<Pivots
|
||||
metas={metas}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
currentMeta={currentMeta}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledMenuContent>
|
||||
|
||||
{showRemovePivots && (
|
||||
<StyledMenuFooter>
|
||||
<StyledPivot
|
||||
onClick={() => {
|
||||
setPageMeta(currentMeta.id, { isPivots: false });
|
||||
const parentMeta = metas.find(m =>
|
||||
m.subpageIds.includes(currentMeta.id)
|
||||
);
|
||||
if (!parentMeta) return;
|
||||
const newSubpageIds = [...parentMeta.subpageIds];
|
||||
const deleteIndex = newSubpageIds.findIndex(
|
||||
id => id === currentMeta.id
|
||||
);
|
||||
newSubpageIds.splice(deleteIndex, 1);
|
||||
setPageMeta(parentMeta.id, { subpageIds: newSubpageIds });
|
||||
}}
|
||||
>
|
||||
<RemoveIcon />
|
||||
{t('Remove from Pivots')}
|
||||
</StyledPivot>
|
||||
<p>{t('RFP')}</p>
|
||||
</StyledMenuFooter>
|
||||
)}
|
||||
</PureMenu>
|
||||
);
|
||||
};
|
@ -1,13 +1,14 @@
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { TreeNodeRender } from './TreeNodeRender';
|
||||
import type { TreeNode } from './types';
|
||||
export const flattenToTree = (
|
||||
handleMetas: PageMeta[],
|
||||
renderProps: { openPage: (pageId: string) => void }
|
||||
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 metas = JSON.parse(JSON.stringify(handleMetas)) as PageMeta[];
|
||||
const rootMetas = metas
|
||||
.filter(meta => {
|
||||
if (meta.subpageIds) {
|
||||
@ -19,29 +20,55 @@ export const flattenToTree = (
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(meta => !meta.trash);
|
||||
.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(meta => !meta.trash);
|
||||
// FIXME: remove ts-ignore after blocksuite update
|
||||
.filter(m => m);
|
||||
// @ts-ignore
|
||||
const returnedMeta: TreeNode = {
|
||||
...internalMeta,
|
||||
children: helper(childrenMetas),
|
||||
render: (node, props) =>
|
||||
TreeNodeRender!(node, props, {
|
||||
pageMeta: internalMeta,
|
||||
pivotRender(node, props, {
|
||||
...renderProps,
|
||||
currentMeta: internalMeta,
|
||||
metas,
|
||||
}),
|
||||
};
|
||||
// @ts-ignore
|
||||
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;
|
198
apps/web/src/components/affine/pivots/hooks/usePivotHandler.ts
Normal file
198
apps/web/src/components/affine/pivots/hooks/usePivotHandler.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import type { TreeViewProps } from '@affine/component';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper';
|
||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
||||
import type { NodeRenderProps, TreeNode } from '../types';
|
||||
|
||||
const logger = new DebugLogger('pivot');
|
||||
|
||||
const findRootIds = (metas: PageMeta[], id: string): string[] => {
|
||||
const parentMeta = metas.find(m => m.subpageIds?.includes(id));
|
||||
if (!parentMeta) {
|
||||
return [id];
|
||||
}
|
||||
return [parentMeta.id, ...findRootIds(metas, parentMeta.id)];
|
||||
};
|
||||
export const usePivotHandler = ({
|
||||
blockSuiteWorkspace,
|
||||
metas,
|
||||
onAdd,
|
||||
onDelete,
|
||||
onDrop,
|
||||
}: {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
metas: PageMeta[];
|
||||
onAdd?: (addedId: string, parentId: string) => void;
|
||||
onDelete?: TreeViewProps<NodeRenderProps>['onDelete'];
|
||||
onDrop?: TreeViewProps<NodeRenderProps>['onDrop'];
|
||||
}) => {
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||
const { getPageMeta, setPageMeta, shiftPageMeta } =
|
||||
usePageMetaHelper(blockSuiteWorkspace);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(node: TreeNode) => {
|
||||
const id = nanoid();
|
||||
createPage(id, node.id);
|
||||
onAdd?.(id, node.id);
|
||||
},
|
||||
[createPage, onAdd]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(node: TreeNode) => {
|
||||
const removeToTrash = (currentMeta: PageMeta) => {
|
||||
const { subpageIds = [] } = currentMeta;
|
||||
setPageMeta(currentMeta.id, {
|
||||
trash: true,
|
||||
trashDate: +new Date(),
|
||||
});
|
||||
subpageIds.forEach(id => {
|
||||
const subcurrentMeta = getPageMeta(id);
|
||||
subcurrentMeta && removeToTrash(subcurrentMeta);
|
||||
});
|
||||
};
|
||||
removeToTrash(metas.find(m => m.id === node.id)!);
|
||||
onDelete?.(node);
|
||||
},
|
||||
[metas, getPageMeta, onDelete, setPageMeta]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(
|
||||
dragId: string,
|
||||
dropId: string,
|
||||
position: {
|
||||
topLine: boolean;
|
||||
bottomLine: boolean;
|
||||
internal: boolean;
|
||||
}
|
||||
) => {
|
||||
if (dragId === dropId) {
|
||||
return;
|
||||
}
|
||||
const dropRootIds = findRootIds(metas, dropId);
|
||||
if (dropRootIds.includes(dragId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { topLine, bottomLine } = position;
|
||||
logger.info('handleDrop', {
|
||||
dragId,
|
||||
dropId,
|
||||
bottomLine,
|
||||
metas,
|
||||
});
|
||||
|
||||
const dragParentMeta = metas.find(meta =>
|
||||
meta.subpageIds?.includes(dragId)
|
||||
);
|
||||
if (bottomLine || topLine) {
|
||||
const insertOffset = bottomLine ? 1 : 0;
|
||||
const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId));
|
||||
|
||||
if (!dropParentMeta) {
|
||||
// drop into root
|
||||
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);
|
||||
newSubpageIds.splice(deleteIndex, 1);
|
||||
|
||||
const insertIndex =
|
||||
newSubpageIds.findIndex(id => id === dropId) + insertOffset;
|
||||
newSubpageIds.splice(insertIndex, 0, dragId);
|
||||
setPageMeta(dropParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
|
||||
return onDrop?.(dragId, dropId, position);
|
||||
}
|
||||
|
||||
logger.info('drop into drop node parent and resort');
|
||||
|
||||
if (dragParentMeta) {
|
||||
const metaIndex = dragParentMeta.subpageIds.findIndex(
|
||||
id => id === dragId
|
||||
);
|
||||
const newSubpageIds = [...dragParentMeta.subpageIds];
|
||||
newSubpageIds.splice(metaIndex, 1);
|
||||
setPageMeta(dragParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
logger.info('drop into the drop node');
|
||||
|
||||
// drop into the node
|
||||
if (dragParentMeta && dragParentMeta.id === dropId) {
|
||||
return;
|
||||
}
|
||||
if (dragParentMeta) {
|
||||
const metaIndex = dragParentMeta.subpageIds.findIndex(
|
||||
id => id === dragId
|
||||
);
|
||||
const newSubpageIds = [...dragParentMeta.subpageIds];
|
||||
newSubpageIds.splice(metaIndex, 1);
|
||||
setPageMeta(dragParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
}
|
||||
const dropMeta = metas.find(meta => meta.id === dropId)!;
|
||||
const newSubpageIds = [dragId, ...(dropMeta.subpageIds ?? [])];
|
||||
setPageMeta(dropMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
},
|
||||
[metas, onDrop, setPageMeta, shiftPageMeta]
|
||||
);
|
||||
|
||||
return {
|
||||
handleDrop,
|
||||
handleAdd,
|
||||
handleDelete,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePivotHandler;
|
5
apps/web/src/components/affine/pivots/index.ts
Normal file
5
apps/web/src/components/affine/pivots/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './hooks/usePivotData';
|
||||
export * from './hooks/usePivotHandler';
|
||||
export * from './PivotRender';
|
||||
export * from './PivotsMenu/PivotsMenu';
|
||||
export * from './types';
|
117
apps/web/src/components/affine/pivots/styles.ts
Normal file
117
apps/web/src/components/affine/pivots/styles.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import {
|
||||
alpha,
|
||||
displayFlex,
|
||||
IconButton,
|
||||
styled,
|
||||
textEllipsis,
|
||||
} from '@affine/component';
|
||||
|
||||
export const StyledCollapsedButton = styled('button')<{
|
||||
collapse: boolean;
|
||||
show?: boolean;
|
||||
}>(({ collapse, show = true, theme }) => {
|
||||
return {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
fontSize: '16px',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
margin: 'auto',
|
||||
color: theme.colors.iconColor,
|
||||
opacity: '.6',
|
||||
display: show ? 'block' : 'none',
|
||||
svg: {
|
||||
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledPivot = styled('div')<{
|
||||
disable?: boolean;
|
||||
active?: boolean;
|
||||
isOver?: boolean;
|
||||
}>(({ disable = false, active = false, theme, isOver }) => {
|
||||
return {
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
...displayFlex('flex-start', 'center'),
|
||||
padding: '0 2px 0 16px',
|
||||
position: 'relative',
|
||||
color: disable
|
||||
? theme.colors.disableColor
|
||||
: active
|
||||
? theme.colors.primaryColor
|
||||
: theme.colors.textColor,
|
||||
cursor: disable ? 'not-allowed' : 'pointer',
|
||||
background: isOver ? alpha(theme.colors.primaryColor, 0.06) : '',
|
||||
fontSize: theme.font.base,
|
||||
span: {
|
||||
flexGrow: '1',
|
||||
textAlign: 'left',
|
||||
...textEllipsis(1),
|
||||
},
|
||||
'> svg': {
|
||||
fontSize: '20px',
|
||||
marginRight: '8px',
|
||||
flexShrink: '0',
|
||||
color: active ? theme.colors.primaryColor : theme.colors.iconColor,
|
||||
},
|
||||
|
||||
':hover': {
|
||||
backgroundColor: disable ? '' : theme.colors.hoverBackground,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledOperationButton = styled(IconButton)<{ visible: boolean }>(
|
||||
({ visible }) => {
|
||||
return {
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const StyledSearchContainer = styled('div')(({ theme }) => {
|
||||
return {
|
||||
width: 'calc(100% - 24px)',
|
||||
margin: '0 auto',
|
||||
...displayFlex('flex-start', 'center'),
|
||||
borderBottom: `1px solid ${theme.colors.borderColor}`,
|
||||
label: {
|
||||
color: theme.colors.iconColor,
|
||||
fontSize: '20px',
|
||||
height: '20px',
|
||||
},
|
||||
};
|
||||
});
|
||||
export const StyledMenuContent = styled('div')(() => {
|
||||
return {
|
||||
height: '266px',
|
||||
overflow: 'auto',
|
||||
};
|
||||
});
|
||||
export const StyledMenuSubTitle = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.colors.secondaryTextColor,
|
||||
lineHeight: '36px',
|
||||
padding: '0 12px',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledMenuFooter = styled('div')(({ theme }) => {
|
||||
return {
|
||||
width: 'calc(100% - 24px)',
|
||||
margin: '0 auto',
|
||||
borderTop: `1px solid ${theme.colors.borderColor}`,
|
||||
padding: '6px 0',
|
||||
|
||||
p: {
|
||||
paddingLeft: '44px',
|
||||
color: theme.colors.secondaryTextColor,
|
||||
fontSize: '14px',
|
||||
},
|
||||
};
|
||||
});
|
18
apps/web/src/components/affine/pivots/types.ts
Normal file
18
apps/web/src/components/affine/pivots/types.ts
Normal file
@ -0,0 +1,18 @@
|
||||
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>;
|
@ -1,8 +1,12 @@
|
||||
import { styled } from '@affine/component';
|
||||
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
|
||||
import { Button } from '@affine/component';
|
||||
import { Input } from '@affine/component';
|
||||
import { MuiAvatar } from '@affine/component';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalWrapper,
|
||||
MuiAvatar,
|
||||
styled,
|
||||
} from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { EmailIcon } from '@blocksuite/icons';
|
||||
import type React from 'react';
|
||||
@ -87,7 +91,7 @@ export const InviteMemberModal = ({
|
||||
setShowMemberPreview(false);
|
||||
}, [])}
|
||||
placeholder={t('Invite placeholder')}
|
||||
></Input>
|
||||
/>
|
||||
{showMemberPreview && gmailReg.test(email) && (
|
||||
<Suspense fallback="loading...">
|
||||
<Result workspaceId={workspaceId} queryEmail={email} />
|
||||
|
@ -20,10 +20,14 @@ import type { PageMeta } from '@blocksuite/store';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
||||
import { toast } from '../../../../utils';
|
||||
import { MoveTo } from '../../../affine/operation-menu-items';
|
||||
|
||||
export type OperationCellProps = {
|
||||
pageMeta: PageMeta;
|
||||
metas: PageMeta[];
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
onOpenPageInNewTab: (pageId: string) => void;
|
||||
onToggleFavoritePage: (pageId: string) => void;
|
||||
onToggleTrashPage: (pageId: string) => void;
|
||||
@ -31,6 +35,8 @@ export type OperationCellProps = {
|
||||
|
||||
export const OperationCell: React.FC<OperationCellProps> = ({
|
||||
pageMeta,
|
||||
metas,
|
||||
blockSuiteWorkspace,
|
||||
onOpenPageInNewTab,
|
||||
onToggleFavoritePage,
|
||||
onToggleTrashPage,
|
||||
@ -59,6 +65,11 @@ export const OperationCell: React.FC<OperationCellProps> = ({
|
||||
>
|
||||
{t('Open in new tab')}
|
||||
</MenuItem>
|
||||
<MoveTo
|
||||
metas={metas}
|
||||
currentMeta={pageMeta}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
<MenuItem
|
||||
data-testid="move-to-trash"
|
||||
onClick={() => {
|
||||
|
@ -1,11 +1,13 @@
|
||||
import {
|
||||
Content,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { Content, IconButton, Tooltip } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import {
|
||||
EdgelessIcon,
|
||||
@ -223,6 +225,8 @@ export const PageList: React.FC<PageListProps> = ({
|
||||
) : (
|
||||
<OperationCell
|
||||
pageMeta={pageMeta}
|
||||
metas={pageList}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
onOpenPageInNewTab={pageId => {
|
||||
onClickPage(pageId, true);
|
||||
}}
|
||||
|
@ -1,21 +1,16 @@
|
||||
// fixme(himself65): refactor this file
|
||||
import { Confirm, FlexWrapper, Menu, MenuItem } from '@affine/component';
|
||||
import { IconButton } from '@affine/component';
|
||||
import { FlexWrapper, IconButton, Menu, MenuItem } from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import {
|
||||
DeleteTemporarilyIcon,
|
||||
ExportIcon,
|
||||
ExportToHtmlIcon,
|
||||
ExportToMarkdownIcon,
|
||||
EdgelessIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
MoreVerticalIcon,
|
||||
PageIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { assertExists } from '@blocksuite/store';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { workspacePreferredModeAtom } from '../../../../atoms';
|
||||
import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id';
|
||||
@ -25,6 +20,11 @@ import {
|
||||
usePageMetaHelper,
|
||||
} from '../../../../hooks/use-page-meta';
|
||||
import { toast } from '../../../../utils';
|
||||
import {
|
||||
Export,
|
||||
MoveTo,
|
||||
MoveToTrash,
|
||||
} from '../../../affine/operation-menu-items';
|
||||
|
||||
export const EditorOptionMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -39,12 +39,12 @@ export const EditorOptionMenu = () => {
|
||||
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
|
||||
meta => meta.id === pageId
|
||||
);
|
||||
const allMetas = usePageMeta(workspace?.blockSuiteWorkspace ?? null);
|
||||
const [record, set] = useAtom(workspacePreferredModeAtom);
|
||||
const mode = record[pageId] ?? 'page';
|
||||
assertExists(pageMeta);
|
||||
const { favorite, trash } = pageMeta;
|
||||
const { favorite } = pageMeta;
|
||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const EditMenu = (
|
||||
<>
|
||||
@ -56,7 +56,6 @@ export const EditorOptionMenu = () => {
|
||||
favorite ? t('Removed from Favorites') : t('Added to Favorites')
|
||||
);
|
||||
}}
|
||||
iconSize={[20, 20]}
|
||||
icon={
|
||||
favorite ? (
|
||||
<FavoritedIcon style={{ color: theme.colors.primaryColor }} />
|
||||
@ -69,7 +68,6 @@ export const EditorOptionMenu = () => {
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={mode === 'page' ? <EdgelessIcon /> : <PageIcon />}
|
||||
iconSize={[20, 20]}
|
||||
data-testid="editor-option-menu-edgeless"
|
||||
onClick={() => {
|
||||
set(record => ({
|
||||
@ -81,48 +79,17 @@ export const EditorOptionMenu = () => {
|
||||
{t('Convert to ')}
|
||||
{mode === 'page' ? t('Edgeless') : t('Page')}
|
||||
</MenuItem>
|
||||
<Menu
|
||||
width={248}
|
||||
placement="left-start"
|
||||
content={
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// @ts-expect-error
|
||||
globalThis.currentEditor.contentParser.onExportHtml();
|
||||
}}
|
||||
icon={<ExportToHtmlIcon />}
|
||||
iconSize={[20, 20]}
|
||||
>
|
||||
{t('Export to HTML')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// @ts-expect-error
|
||||
globalThis.currentEditor.contentParser.onExportMarkdown();
|
||||
}}
|
||||
icon={<ExportToMarkdownIcon />}
|
||||
iconSize={[20, 20]}
|
||||
>
|
||||
{t('Export to Markdown')}
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuItem icon={<ExportIcon />} iconSize={[20, 20]} isDir={true}>
|
||||
{t('Export')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<MenuItem
|
||||
data-testid="editor-option-menu-delete"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
icon={<DeleteTemporarilyIcon />}
|
||||
iconSize={[20, 20]}
|
||||
>
|
||||
{t('Delete')}
|
||||
</MenuItem>
|
||||
<Export />
|
||||
<MoveTo
|
||||
metas={allMetas}
|
||||
currentMeta={pageMeta}
|
||||
blockSuiteWorkspace={workspace?.blockSuiteWorkspace}
|
||||
/>
|
||||
<MoveToTrash
|
||||
testId="editor-option-menu-delete"
|
||||
currentMeta={pageMeta}
|
||||
blockSuiteWorkspace={workspace?.blockSuiteWorkspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -141,26 +108,6 @@ export const EditorOptionMenu = () => {
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</FlexWrapper>
|
||||
<Confirm
|
||||
title={t('Delete page?')}
|
||||
content={t('will be moved to Trash', {
|
||||
title: pageMeta.title || 'Untitled',
|
||||
})}
|
||||
confirmText={t('Delete')}
|
||||
confirmType="danger"
|
||||
open={open}
|
||||
onConfirm={() => {
|
||||
toast(t('Moved to Trash'));
|
||||
setOpen(false);
|
||||
setPageMeta(pageId, { trash: !trash, trashDate: +new Date() });
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
120
apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx
Normal file
120
apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
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 { RemWorkspace } 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 = ({
|
||||
currentWorkspace,
|
||||
openPage,
|
||||
allMetas,
|
||||
}: {
|
||||
currentWorkspace: RemWorkspace;
|
||||
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: currentWorkspace.blockSuiteWorkspace,
|
||||
onClick: handlePivotClick,
|
||||
showOperationButton: true,
|
||||
});
|
||||
|
||||
const { handleAdd, handleDelete, handleDrop } = usePivotHandler({
|
||||
blockSuiteWorkspace: currentWorkspace.blockSuiteWorkspace,
|
||||
|
||||
metas: allMetas,
|
||||
onAdd,
|
||||
});
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
data={data}
|
||||
onAdd={handleAdd}
|
||||
onDelete={handleDelete}
|
||||
onDrop={handleDrop}
|
||||
indent={16}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pivots = ({
|
||||
currentWorkspace,
|
||||
openPage,
|
||||
allMetas,
|
||||
}: {
|
||||
currentWorkspace: RemWorkspace;
|
||||
openPage: (pageId: string) => void;
|
||||
allMetas: PageMeta[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showPivot, setShowPivot] = useState(true);
|
||||
|
||||
const isPivotEmpty = useMemo(
|
||||
() => allMetas.filter(meta => !meta.trash).length === 0,
|
||||
[allMetas]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledListItem>
|
||||
<StyledCollapseButton
|
||||
onClick={useCallback(() => {
|
||||
setShowPivot(!showPivot);
|
||||
}, [showPivot])}
|
||||
collapse={showPivot}
|
||||
>
|
||||
<ArrowDownSmallIcon />
|
||||
</StyledCollapseButton>
|
||||
<PivotsIcon />
|
||||
{t('Pivots')}
|
||||
</StyledListItem>
|
||||
|
||||
<MuiCollapse
|
||||
in={showPivot}
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
paddingLeft: '16px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{isPivotEmpty ? (
|
||||
<EmptyItem />
|
||||
) : (
|
||||
<PivotInternal
|
||||
currentWorkspace={currentWorkspace}
|
||||
openPage={openPage}
|
||||
allMetas={allMetas}
|
||||
/>
|
||||
)}
|
||||
</MuiCollapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Pivots;
|
@ -16,7 +16,7 @@ import { usePageMeta } from '../../../hooks/use-page-meta';
|
||||
import type { RemWorkspace } from '../../../shared';
|
||||
import { SidebarSwitch } from '../../affine/sidebar-switch';
|
||||
import Favorite from './favorite';
|
||||
import { Pivot } from './pivot';
|
||||
import { Pivots } from './Pivots';
|
||||
import { StyledListItem } from './shared-styles';
|
||||
import {
|
||||
StyledLink,
|
||||
@ -142,7 +142,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
|
||||
currentWorkspace={currentWorkspace}
|
||||
/>
|
||||
{config.enableSubpage && !!currentWorkspace && (
|
||||
<Pivot
|
||||
<Pivots
|
||||
currentWorkspace={currentWorkspace}
|
||||
openPage={openPage}
|
||||
allMetas={pageMeta}
|
||||
|
@ -1,110 +0,0 @@
|
||||
import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MuiClickAwayListener,
|
||||
PureMenu,
|
||||
} from '@affine/component';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteTemporarilyIcon,
|
||||
MoreVerticalIcon,
|
||||
MoveToIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { toast } from '../../../../utils';
|
||||
|
||||
export const OperationButton = ({
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
onAdd: () => void;
|
||||
onDelete: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const copyUrl = useCallback(() => {
|
||||
const workspaceId = router.query.workspaceId;
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast(t('Copied link to clipboard'));
|
||||
}, [router.query.workspaceId, t]);
|
||||
|
||||
return (
|
||||
<MuiClickAwayListener
|
||||
onClickAway={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
ref={ref => setAnchorEl(ref)}
|
||||
size="small"
|
||||
className="operation-button"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
</IconButton>
|
||||
<PureMenu
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-start"
|
||||
open={open && anchorEl !== null}
|
||||
zIndex={11111}
|
||||
>
|
||||
<MenuItem
|
||||
icon={<PlusIcon />}
|
||||
onClick={() => {
|
||||
onAdd();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('Add a subpage inside')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<MoveToIcon />} disabled={true}>
|
||||
{t('Move to')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PenIcon />} disabled={true}>
|
||||
{t('Rename')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<DeleteTemporarilyIcon />}
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('Move to Trash')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<CopyIcon />}
|
||||
disabled={true}
|
||||
// onClick={() => {
|
||||
// const workspaceId = router.query.workspaceId;
|
||||
// navigator.clipboard.writeText(window.location.href);
|
||||
// toast(t('Copied link to clipboard'));
|
||||
// }}
|
||||
>
|
||||
{t('Copy Link')}
|
||||
</MenuItem>
|
||||
</PureMenu>
|
||||
</div>
|
||||
</MuiClickAwayListener>
|
||||
);
|
||||
};
|
@ -1,242 +0,0 @@
|
||||
import { MuiCollapse, TreeView } from '@affine/component';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { useTranslation } from '@affine/i18n';
|
||||
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper';
|
||||
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
|
||||
import type { RemWorkspace } from '../../../../shared';
|
||||
import EmptyItem from '../favorite/empty-item';
|
||||
import { StyledCollapseButton, StyledListItem } from '../shared-styles';
|
||||
import type { TreeNode } from './types';
|
||||
import { flattenToTree } from './utils';
|
||||
const logger = new DebugLogger('pivot');
|
||||
|
||||
export const PivotInternal = ({
|
||||
currentWorkspace,
|
||||
openPage,
|
||||
allMetas,
|
||||
}: {
|
||||
currentWorkspace: RemWorkspace;
|
||||
openPage: (pageId: string) => void;
|
||||
allMetas: PageMeta[];
|
||||
}) => {
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const { getPageMeta, setPageMeta, shiftPageMeta } = usePageMetaHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
|
||||
const treeData = useMemo(
|
||||
() => flattenToTree(allMetas, { openPage }),
|
||||
[allMetas, openPage]
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(node: TreeNode) => {
|
||||
const id = nanoid();
|
||||
createPage(id, node.id);
|
||||
openPage(id);
|
||||
},
|
||||
[createPage, openPage]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(node: TreeNode) => {
|
||||
const removeToTrash = (pageMeta: PageMeta) => {
|
||||
const { subpageIds = [] } = pageMeta;
|
||||
setPageMeta(pageMeta.id, { trash: true, trashDate: +new Date() });
|
||||
subpageIds.forEach(id => {
|
||||
const subpageMeta = getPageMeta(id);
|
||||
subpageMeta && removeToTrash(subpageMeta);
|
||||
});
|
||||
};
|
||||
removeToTrash(node as PageMeta);
|
||||
},
|
||||
[getPageMeta, setPageMeta]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(
|
||||
dragNode: TreeNode,
|
||||
dropNode: TreeNode,
|
||||
position: {
|
||||
topLine: boolean;
|
||||
bottomLine: boolean;
|
||||
internal: boolean;
|
||||
}
|
||||
) => {
|
||||
const { topLine, bottomLine } = position;
|
||||
logger.info('handleDrop', { dragNode, dropNode, bottomLine, allMetas });
|
||||
|
||||
const dragParentMeta = allMetas.find(meta =>
|
||||
meta.subpageIds?.includes(dragNode.id)
|
||||
);
|
||||
if (bottomLine || topLine) {
|
||||
const insertOffset = bottomLine ? 1 : 0;
|
||||
const dropParentMeta = allMetas.find(m =>
|
||||
m.subpageIds?.includes(dropNode.id)
|
||||
);
|
||||
|
||||
if (!dropParentMeta) {
|
||||
// drop into root
|
||||
logger.info('drop into root and resort');
|
||||
|
||||
if (dragParentMeta) {
|
||||
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
|
||||
|
||||
const deleteIndex = dragParentMeta.subpageIds?.findIndex(
|
||||
id => id === dragNode.id
|
||||
);
|
||||
newSubpageIds.splice(deleteIndex, 1);
|
||||
setPageMeta(dragParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('resort root meta');
|
||||
const insertIndex =
|
||||
allMetas.findIndex(m => m.id === dropNode.id) + insertOffset;
|
||||
shiftPageMeta(dragNode.id, insertIndex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
dragParentMeta &&
|
||||
(dragParentMeta.id === dropNode.id ||
|
||||
dragParentMeta.id === dropParentMeta!.id)
|
||||
) {
|
||||
logger.info('drop to resort');
|
||||
// need to resort
|
||||
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
|
||||
|
||||
const deleteIndex = newSubpageIds.findIndex(id => id === dragNode.id);
|
||||
newSubpageIds.splice(deleteIndex, 1);
|
||||
|
||||
const insertIndex =
|
||||
newSubpageIds.findIndex(id => id === dropNode.id) + insertOffset;
|
||||
newSubpageIds.splice(insertIndex, 0, dragNode.id);
|
||||
setPageMeta(dropParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('drop into drop node parent and resort');
|
||||
|
||||
if (dragParentMeta) {
|
||||
const metaIndex = dragParentMeta.subpageIds.findIndex(
|
||||
id => id === dragNode.id
|
||||
);
|
||||
const newSubpageIds = [...dragParentMeta.subpageIds];
|
||||
newSubpageIds.splice(metaIndex, 1);
|
||||
setPageMeta(dragParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
}
|
||||
const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])];
|
||||
const insertIndex =
|
||||
newSubpageIds.findIndex(id => id === dropNode.id) + 1;
|
||||
newSubpageIds.splice(insertIndex, 0, dragNode.id);
|
||||
setPageMeta(dropParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('drop into the drop node');
|
||||
|
||||
// drop into the node
|
||||
if (dragParentMeta && dragParentMeta.id === dropNode.id) {
|
||||
return;
|
||||
}
|
||||
if (dragParentMeta) {
|
||||
const metaIndex = dragParentMeta.subpageIds.findIndex(
|
||||
id => id === dragNode.id
|
||||
);
|
||||
const newSubpageIds = [...dragParentMeta.subpageIds];
|
||||
newSubpageIds.splice(metaIndex, 1);
|
||||
setPageMeta(dragParentMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
}
|
||||
const dropMeta = allMetas.find(meta => meta.id === dropNode.id)!;
|
||||
const newSubpageIds = [dragNode.id, ...(dropMeta.subpageIds ?? [])];
|
||||
setPageMeta(dropMeta.id, {
|
||||
subpageIds: newSubpageIds,
|
||||
});
|
||||
},
|
||||
[allMetas, setPageMeta, shiftPageMeta]
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
data={treeData}
|
||||
onAdd={handleAdd}
|
||||
onDelete={handleDelete}
|
||||
onDrop={handleDrop}
|
||||
indent={16}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pivot = ({
|
||||
currentWorkspace,
|
||||
openPage,
|
||||
allMetas,
|
||||
}: {
|
||||
currentWorkspace: RemWorkspace;
|
||||
openPage: (pageId: string) => void;
|
||||
allMetas: PageMeta[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showPivot, setShowPivot] = useState(true);
|
||||
|
||||
const isPivotEmpty = useMemo(
|
||||
() => allMetas.filter(meta => !meta.trash).length === 0,
|
||||
[allMetas]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledListItem>
|
||||
<StyledCollapseButton
|
||||
onClick={useCallback(() => {
|
||||
setShowPivot(!showPivot);
|
||||
}, [showPivot])}
|
||||
collapse={showPivot}
|
||||
>
|
||||
<ArrowDownSmallIcon />
|
||||
</StyledCollapseButton>
|
||||
<PivotsIcon />
|
||||
{t('Pivots')}
|
||||
</StyledListItem>
|
||||
|
||||
<MuiCollapse
|
||||
in={showPivot}
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
paddingLeft: '16px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{isPivotEmpty ? (
|
||||
<EmptyItem />
|
||||
) : (
|
||||
<PivotInternal
|
||||
currentWorkspace={currentWorkspace}
|
||||
openPage={openPage}
|
||||
allMetas={allMetas}
|
||||
/>
|
||||
)}
|
||||
</MuiCollapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Pivot;
|
@ -1,51 +0,0 @@
|
||||
import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { workspacePreferredModeAtom } from '../../../../atoms';
|
||||
import { StyledCollapseButton, StyledCollapseItem } from '../shared-styles';
|
||||
import { OperationButton } from './OperationButton';
|
||||
import type { TreeNode } from './types';
|
||||
|
||||
export const TreeNodeRender: TreeNode['render'] = (
|
||||
node,
|
||||
{ isOver, onAdd, onDelete, collapsed, setCollapsed },
|
||||
extendProps
|
||||
) => {
|
||||
const { openPage, pageMeta } = extendProps as {
|
||||
openPage: (pageId: string) => void;
|
||||
pageMeta: PageMeta;
|
||||
};
|
||||
const record = useAtomValue(workspacePreferredModeAtom);
|
||||
|
||||
const router = useRouter();
|
||||
const active = router.query.pageId === node.id;
|
||||
|
||||
return (
|
||||
<StyledCollapseItem
|
||||
onClick={() => {
|
||||
if (active) {
|
||||
return;
|
||||
}
|
||||
openPage(node.id);
|
||||
}}
|
||||
isOver={isOver}
|
||||
active={active}
|
||||
>
|
||||
<StyledCollapseButton
|
||||
collapse={collapsed}
|
||||
show={!!node.children?.length}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
>
|
||||
<ArrowDownSmallIcon />
|
||||
</StyledCollapseButton>
|
||||
{record[pageMeta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
|
||||
<span>{node.title || 'Untitled'}</span>
|
||||
<OperationButton onAdd={onAdd} onDelete={onDelete} />
|
||||
</StyledCollapseItem>
|
||||
);
|
||||
};
|
@ -1,2 +0,0 @@
|
||||
export * from './Pivot';
|
||||
export * from './types';
|
@ -1,29 +0,0 @@
|
||||
import { IconButton, styled } from '@affine/component';
|
||||
|
||||
export const StyledOperationButton = styled('button')(({ theme }) => {
|
||||
return {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
fontSize: '20px',
|
||||
color: theme.colors.iconColor,
|
||||
display: 'none',
|
||||
':hover': {
|
||||
background: theme.colors.hoverBackground,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledCollapsedButton = styled(IconButton, {
|
||||
shouldForwardProp: prop => {
|
||||
return !['show'].includes(prop as string);
|
||||
},
|
||||
})<{ show: boolean }>(({ show }) => {
|
||||
return {
|
||||
display: show ? 'block' : 'none',
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
bottom: '0px',
|
||||
margin: 'auto',
|
||||
};
|
||||
});
|
@ -1,4 +0,0 @@
|
||||
import type { Node } from '@affine/component';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
export type TreeNode = Node<PageMeta>;
|
@ -11,6 +11,8 @@ declare module '@blocksuite/store' {
|
||||
trashDate?: number;
|
||||
// whether to create the page with the default template
|
||||
init?: boolean;
|
||||
// use for subpage
|
||||
isPivots?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
ForwardedRef,
|
||||
HTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
} from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
import { StyledInput } from './style';
|
||||
|
||||
@ -14,13 +14,14 @@ type inputProps = {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
noBorder?: boolean;
|
||||
} & Omit<HTMLAttributes<HTMLInputElement>, 'onChange'>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
|
||||
@ -31,10 +32,11 @@ export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
|
||||
maxLength,
|
||||
minLength,
|
||||
height,
|
||||
width = 260,
|
||||
width,
|
||||
onChange,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
noBorder = false,
|
||||
...otherProps
|
||||
}: inputProps,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
@ -69,7 +71,8 @@ export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
height={height}
|
||||
noBorder={noBorder}
|
||||
{...otherProps}
|
||||
></StyledInput>
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,28 +1,25 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
|
||||
export const StyledInput = styled('input')<{
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
width: number;
|
||||
height?: number;
|
||||
}>(({ theme, width, disabled, height }) => {
|
||||
const fontWeight = 400;
|
||||
const fontSize = '16px';
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
noBorder?: boolean;
|
||||
}>(({ theme, width, disabled, height, noBorder }) => {
|
||||
return {
|
||||
width: `${width}px`,
|
||||
width: width || '100%',
|
||||
height,
|
||||
lineHeight: '22px',
|
||||
padding: '8px 12px',
|
||||
fontWeight,
|
||||
fontSize,
|
||||
height: height ? `${height}px` : 'auto',
|
||||
color: disabled ? theme.colors.disableColor : theme.colors.textColor,
|
||||
border: `1px solid`,
|
||||
border: noBorder ? 'unset' : `1px solid`,
|
||||
borderColor: theme.colors.borderColor, // TODO: check out disableColor,
|
||||
backgroundColor: theme.colors.popoverBackground,
|
||||
borderRadius: '10px',
|
||||
'&::placeholder': {
|
||||
fontWeight,
|
||||
fontSize,
|
||||
color: theme.colors.placeHolderColor,
|
||||
},
|
||||
'&:focus': {
|
||||
|
@ -1,31 +1,28 @@
|
||||
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
|
||||
import { cloneElement, forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import {
|
||||
StyledContent,
|
||||
StyledEndIconWrapper,
|
||||
StyledMenuItem,
|
||||
StyledStartIconWrapper,
|
||||
} from './styles';
|
||||
|
||||
import { StyledArrow, StyledMenuItem } from './styles';
|
||||
export type IconMenuProps = PropsWithChildren<{
|
||||
isDir?: boolean;
|
||||
icon?: ReactElement;
|
||||
endIcon?: ReactElement;
|
||||
iconSize?: [number, number];
|
||||
disabled?: boolean;
|
||||
}> &
|
||||
HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const MenuItem = forwardRef<HTMLButtonElement, IconMenuProps>(
|
||||
({ isDir = false, icon, iconSize, children, ...props }, ref) => {
|
||||
const [iconWidth, iconHeight] = iconSize || [20, 20];
|
||||
({ endIcon, icon, iconSize, children, ...props }, ref) => {
|
||||
return (
|
||||
<StyledMenuItem ref={ref} {...props}>
|
||||
{icon &&
|
||||
cloneElement(icon, {
|
||||
width: iconWidth,
|
||||
height: iconHeight,
|
||||
style: {
|
||||
marginRight: 12,
|
||||
...icon.props?.style,
|
||||
},
|
||||
})}
|
||||
{children}
|
||||
{isDir ? <StyledArrow /> : null}
|
||||
{icon && <StyledStartIconWrapper>{icon}</StyledStartIconWrapper>}
|
||||
<StyledContent>{children}</StyledContent>
|
||||
{endIcon && <StyledEndIconWrapper>{endIcon}</StyledEndIconWrapper>}
|
||||
</StyledMenuItem>
|
||||
);
|
||||
}
|
||||
|
@ -4,12 +4,17 @@ import type { PurePopperProps } from '../popper';
|
||||
import { PurePopper } from '../popper';
|
||||
import { StyledMenuWrapper } from './styles';
|
||||
|
||||
export type PureMenuProps = PurePopperProps & {
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
};
|
||||
export const PureMenu = ({
|
||||
children,
|
||||
placement,
|
||||
width,
|
||||
height,
|
||||
...otherProps
|
||||
}: PurePopperProps & { width?: CSSProperties['width'] }) => {
|
||||
}: PureMenuProps) => {
|
||||
return (
|
||||
<PurePopper placement={placement} {...otherProps}>
|
||||
<StyledMenuWrapper width={width} placement={placement}>
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { displayFlex, styled } from '../../styles';
|
||||
import { displayFlex, styled, textEllipsis } from '../../styles';
|
||||
import StyledPopperContainer from '../shared/Container';
|
||||
|
||||
export const StyledMenuWrapper = styled(StyledPopperContainer)<{
|
||||
width?: CSSProperties['width'];
|
||||
}>(({ theme, width }) => {
|
||||
height?: CSSProperties['height'];
|
||||
}>(({ theme, width, height }) => {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
background: theme.colors.popoverBackground,
|
||||
padding: '8px 4px',
|
||||
fontSize: '14px',
|
||||
@ -17,13 +18,28 @@ export const StyledMenuWrapper = styled(StyledPopperContainer)<{
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledArrow = styled(ArrowRightSmallIcon)({
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
margin: 'auto',
|
||||
fontSize: '20px',
|
||||
export const StyledStartIconWrapper = styled('div')(({ theme }) => {
|
||||
return {
|
||||
marginRight: '12px',
|
||||
fontSize: '20px',
|
||||
color: theme.colors.iconColor,
|
||||
};
|
||||
});
|
||||
export const StyledEndIconWrapper = styled('div')(({ theme }) => {
|
||||
return {
|
||||
marginLeft: '12px',
|
||||
fontSize: '20px',
|
||||
color: theme.colors.iconColor,
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
textAlign: 'left',
|
||||
flexGrow: 1,
|
||||
fontSize: theme.font.base,
|
||||
...textEllipsis(1),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledMenuItem = styled('button')<{
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
|
||||
import {
|
||||
@ -14,21 +14,21 @@ import type {
|
||||
TreeNodeProps,
|
||||
} from './types';
|
||||
|
||||
const NodeLine = <N,>({
|
||||
const NodeLine = <RenderProps,>({
|
||||
node,
|
||||
onDrop,
|
||||
allowDrop = true,
|
||||
isTop = false,
|
||||
}: NodeLIneProps<N>) => {
|
||||
}: NodeLIneProps<RenderProps>) => {
|
||||
const [{ isOver }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: 'node',
|
||||
drop: (item: Node<N>, monitor) => {
|
||||
drop: (item: Node<RenderProps>, monitor) => {
|
||||
const didDrop = monitor.didDrop();
|
||||
if (didDrop) {
|
||||
return;
|
||||
}
|
||||
onDrop?.(item, node, {
|
||||
onDrop?.(item.id, node.id, {
|
||||
internal: false,
|
||||
topLine: isTop,
|
||||
bottomLine: !isTop,
|
||||
@ -44,24 +44,23 @@ const NodeLine = <N,>({
|
||||
|
||||
return <StyledNodeLine ref={drop} show={isOver && allowDrop} isTop={isTop} />;
|
||||
};
|
||||
const TreeNodeItem = <N,>({
|
||||
const TreeNodeItemWithDnd = <RenderProps,>({
|
||||
node,
|
||||
allowDrop,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
...otherProps
|
||||
}: TreeNodeItemProps<N>) => {
|
||||
}: TreeNodeItemProps<RenderProps>) => {
|
||||
const { onAdd, onDelete, onDrop } = otherProps;
|
||||
|
||||
const [{ canDrop, isOver }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: 'node',
|
||||
drop: (item: Node<N>, monitor) => {
|
||||
drop: (item: Node<RenderProps>, monitor) => {
|
||||
const didDrop = monitor.didDrop();
|
||||
if (didDrop || item.id === node.id || !allowDrop) {
|
||||
return;
|
||||
}
|
||||
onDrop?.(item, node, {
|
||||
onDrop?.(item.id, node.id, {
|
||||
internal: true,
|
||||
topLine: false,
|
||||
bottomLine: false,
|
||||
@ -77,44 +76,79 @@ const TreeNodeItem = <N,>({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOver && canDrop) {
|
||||
setCollapsed(false);
|
||||
setCollapsed(node.id, false);
|
||||
}
|
||||
}, [isOver, canDrop]);
|
||||
|
||||
return (
|
||||
<div ref={drop}>
|
||||
<TreeNodeItem
|
||||
dropRef={drop}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
node={node}
|
||||
allowDrop={allowDrop}
|
||||
setCollapsed={setCollapsed}
|
||||
isOver={isOver}
|
||||
canDrop={canDrop}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TreeNodeItem = <RenderProps,>({
|
||||
node,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
selectedId,
|
||||
isOver = false,
|
||||
canDrop = false,
|
||||
onAdd,
|
||||
onDelete,
|
||||
dropRef,
|
||||
}: TreeNodeItemProps<RenderProps>) => {
|
||||
return (
|
||||
<div ref={dropRef}>
|
||||
{node.render?.(node, {
|
||||
isOver: !!(isOver && canDrop),
|
||||
isOver: isOver && canDrop,
|
||||
onAdd: () => onAdd?.(node),
|
||||
onDelete: () => onDelete?.(node),
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
isSelected: selectedId === node.id,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TreeNode = <N,>({
|
||||
node,
|
||||
index,
|
||||
allowDrop = true,
|
||||
...otherProps
|
||||
}: TreeNodeProps<N>) => {
|
||||
const { indent } = otherProps;
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
export const TreeNodeWithDnd = <RenderProps,>(
|
||||
props: TreeNodeProps<RenderProps>
|
||||
) => {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: 'node',
|
||||
item: node,
|
||||
item: props.node,
|
||||
collect: monitor => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}));
|
||||
|
||||
return <TreeNode dragRef={drag} isDragging={isDragging} {...props} />;
|
||||
};
|
||||
|
||||
export const TreeNode = <RenderProps,>({
|
||||
node,
|
||||
index,
|
||||
isDragging = false,
|
||||
allowDrop = true,
|
||||
dragRef,
|
||||
...otherProps
|
||||
}: TreeNodeProps<RenderProps>) => {
|
||||
const { indent, enableDnd, collapsedIds } = otherProps;
|
||||
const collapsed = collapsedIds.includes(node.id);
|
||||
|
||||
return (
|
||||
<StyledTreeNodeContainer ref={drag} isDragging={isDragging}>
|
||||
<StyledTreeNodeContainer ref={dragRef} isDragging={isDragging}>
|
||||
<StyledTreeNodeWrapper>
|
||||
{index === 0 && (
|
||||
{enableDnd && index === 0 && (
|
||||
<NodeLine
|
||||
node={node}
|
||||
{...otherProps}
|
||||
@ -122,15 +156,25 @@ export const TreeNode = <N,>({
|
||||
isTop={true}
|
||||
/>
|
||||
)}
|
||||
<TreeNodeItem
|
||||
node={node}
|
||||
index={index}
|
||||
allowDrop={allowDrop}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
{...otherProps}
|
||||
/>
|
||||
{(!node.children?.length || collapsed) && (
|
||||
{enableDnd ? (
|
||||
<TreeNodeItemWithDnd
|
||||
node={node}
|
||||
index={index}
|
||||
allowDrop={allowDrop}
|
||||
collapsed={collapsed}
|
||||
{...otherProps}
|
||||
/>
|
||||
) : (
|
||||
<TreeNodeItem
|
||||
node={node}
|
||||
index={index}
|
||||
allowDrop={allowDrop}
|
||||
collapsed={collapsed}
|
||||
{...otherProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enableDnd && (!node.children?.length || collapsed) && (
|
||||
<NodeLine
|
||||
node={node}
|
||||
{...otherProps}
|
||||
@ -140,18 +184,26 @@ export const TreeNode = <N,>({
|
||||
</StyledTreeNodeWrapper>
|
||||
<StyledCollapse in={!collapsed} indent={indent}>
|
||||
{node.children &&
|
||||
node.children.map((childNode, index) => (
|
||||
<TreeNode
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
index={index}
|
||||
allowDrop={isDragging ? false : allowDrop}
|
||||
{...otherProps}
|
||||
/>
|
||||
))}
|
||||
node.children.map((childNode, index) =>
|
||||
enableDnd ? (
|
||||
<TreeNodeWithDnd
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
index={index}
|
||||
allowDrop={isDragging ? false : allowDrop}
|
||||
{...otherProps}
|
||||
/>
|
||||
) : (
|
||||
<TreeNode
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
index={index}
|
||||
allowDrop={false}
|
||||
{...otherProps}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</StyledCollapse>
|
||||
</StyledTreeNodeContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeNode;
|
||||
|
@ -1,15 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
import { TreeNode } from './TreeNode';
|
||||
import type { TreeViewProps } from './types';
|
||||
export const TreeView = <N,>({ data, ...otherProps }: TreeViewProps<N>) => {
|
||||
import { TreeNode, TreeNodeWithDnd } from './TreeNode';
|
||||
import type { TreeNodeProps, TreeViewProps } from './types';
|
||||
import { flattenIds } from './utils';
|
||||
|
||||
export const TreeView = <RenderProps,>({
|
||||
data,
|
||||
enableKeyboardSelection,
|
||||
onSelect,
|
||||
enableDnd = true,
|
||||
initialCollapsedIds = [],
|
||||
...otherProps
|
||||
}: TreeViewProps<RenderProps>) => {
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
// TODO: should record collapsedIds in localStorage
|
||||
const [collapsedIds, setCollapsedIds] =
|
||||
useState<string[]>(initialCollapsedIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableKeyboardSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flattenedIds = flattenIds<RenderProps>(data);
|
||||
|
||||
const handleDirectionKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
|
||||
return;
|
||||
}
|
||||
if (selectedId === undefined) {
|
||||
setSelectedId(flattenedIds[0]);
|
||||
return;
|
||||
}
|
||||
let selectedIndex = flattenedIds.indexOf(selectedId);
|
||||
if (e.key === 'ArrowDown') {
|
||||
selectedIndex < flattenedIds.length - 1 && selectedIndex++;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
selectedIndex > 0 && selectedIndex--;
|
||||
}
|
||||
|
||||
setSelectedId(flattenedIds[selectedIndex]);
|
||||
};
|
||||
|
||||
const handleEnterKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
selectedId && onSelect?.(selectedId);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleDirectionKeyDown);
|
||||
document.addEventListener('keydown', handleEnterKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleDirectionKeyDown);
|
||||
document.removeEventListener('keydown', handleEnterKeyDown);
|
||||
};
|
||||
}, [data, selectedId]);
|
||||
|
||||
const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => {
|
||||
if (collapsed) {
|
||||
setCollapsedIds(ids => [...ids, id]);
|
||||
} else {
|
||||
setCollapsedIds(ids => ids.filter(i => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
if (enableDnd) {
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{data.map((node, index) => (
|
||||
<TreeNodeWithDnd
|
||||
key={node.id}
|
||||
index={index}
|
||||
collapsedIds={collapsedIds}
|
||||
setCollapsed={setCollapsed}
|
||||
node={node}
|
||||
selectedId={selectedId}
|
||||
enableDnd={enableDnd}
|
||||
{...otherProps}
|
||||
/>
|
||||
))}
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<>
|
||||
{data.map((node, index) => (
|
||||
<TreeNode key={node.id} index={index} node={node} {...otherProps} />
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
index={index}
|
||||
collapsedIds={collapsedIds}
|
||||
setCollapsed={setCollapsed}
|
||||
node={node}
|
||||
selectedId={selectedId}
|
||||
enableDnd={enableDnd}
|
||||
{...otherProps}
|
||||
/>
|
||||
))}
|
||||
</DndProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -15,8 +15,8 @@ export const StyledTreeNodeWrapper = styled('div')(() => {
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
export const StyledTreeNodeContainer = styled('div')<{ isDragging: boolean }>(
|
||||
({ isDragging, theme }) => {
|
||||
export const StyledTreeNodeContainer = styled('div')<{ isDragging?: boolean }>(
|
||||
({ isDragging = false, theme }) => {
|
||||
return {
|
||||
background: isDragging ? theme.colors.hoverBackground : '',
|
||||
// opacity: isDragging ? 0.4 : 1,
|
||||
|
@ -1,52 +1,71 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactNode, Ref } from 'react';
|
||||
|
||||
export type Node<N> = {
|
||||
export type DropPosition = {
|
||||
topLine: boolean;
|
||||
bottomLine: boolean;
|
||||
internal: boolean;
|
||||
};
|
||||
export type OnDrop = (
|
||||
dragId: string,
|
||||
dropId: string,
|
||||
position: DropPosition
|
||||
) => void;
|
||||
|
||||
export type Node<RenderProps = unknown> = {
|
||||
id: string;
|
||||
children?: Node<N>[];
|
||||
render?: (
|
||||
node: Node<N>,
|
||||
children?: Node<RenderProps>[];
|
||||
render: (
|
||||
node: Node<RenderProps>,
|
||||
eventsAndStatus: {
|
||||
isOver: boolean;
|
||||
onAdd: () => void;
|
||||
onDelete: () => void;
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
setCollapsed: (id: string, collapsed: boolean) => void;
|
||||
isSelected: boolean;
|
||||
},
|
||||
extendProps?: unknown
|
||||
renderProps?: RenderProps
|
||||
) => ReactNode;
|
||||
} & N;
|
||||
|
||||
type CommonProps<N> = {
|
||||
indent?: CSSProperties['paddingLeft'];
|
||||
onAdd?: (node: Node<N>) => void;
|
||||
onDelete?: (node: Node<N>) => void;
|
||||
onDrop?: (
|
||||
dragNode: Node<N>,
|
||||
dropNode: Node<N>,
|
||||
position: {
|
||||
topLine: boolean;
|
||||
bottomLine: boolean;
|
||||
internal: boolean;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type TreeNodeProps<N> = {
|
||||
node: Node<N>;
|
||||
type CommonProps<RenderProps = unknown> = {
|
||||
enableDnd?: boolean;
|
||||
enableKeyboardSelection?: boolean;
|
||||
indent?: CSSProperties['paddingLeft'];
|
||||
onAdd?: (node: Node<RenderProps>) => void;
|
||||
onDelete?: (node: Node<RenderProps>) => void;
|
||||
onDrop?: OnDrop;
|
||||
// Only trigger when the enableKeyboardSelection is true
|
||||
onSelect?: (id: string) => void;
|
||||
};
|
||||
|
||||
export type TreeNodeProps<RenderProps = unknown> = {
|
||||
node: Node<RenderProps>;
|
||||
index: number;
|
||||
collapsedIds: string[];
|
||||
setCollapsed: (id: string, collapsed: boolean) => void;
|
||||
allowDrop?: boolean;
|
||||
} & CommonProps<N>;
|
||||
selectedId?: string;
|
||||
isDragging?: boolean;
|
||||
dragRef?: Ref<HTMLDivElement>;
|
||||
} & CommonProps<RenderProps>;
|
||||
|
||||
export type TreeNodeItemProps<N> = {
|
||||
export type TreeNodeItemProps<RenderProps = unknown> = {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
} & TreeNodeProps<N>;
|
||||
setCollapsed: (id: string, collapsed: boolean) => void;
|
||||
|
||||
export type TreeViewProps<N> = {
|
||||
data: Node<N>[];
|
||||
} & CommonProps<N>;
|
||||
isOver?: boolean;
|
||||
canDrop?: boolean;
|
||||
|
||||
export type NodeLIneProps<N> = {
|
||||
dropRef?: Ref<HTMLDivElement>;
|
||||
} & TreeNodeProps<RenderProps>;
|
||||
|
||||
export type TreeViewProps<RenderProps = unknown> = {
|
||||
data: Node<RenderProps>[];
|
||||
initialCollapsedIds?: string[];
|
||||
} & CommonProps<RenderProps>;
|
||||
|
||||
export type NodeLIneProps<RenderProps = unknown> = {
|
||||
allowDrop: boolean;
|
||||
isTop?: boolean;
|
||||
} & Pick<TreeNodeProps<N>, 'node' | 'onDrop'>;
|
||||
} & Pick<TreeNodeProps<RenderProps>, 'node' | 'onDrop'>;
|
||||
|
18
packages/component/src/ui/tree-view/utils.ts
Normal file
18
packages/component/src/ui/tree-view/utils.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Node } from '@affine/component';
|
||||
|
||||
export function flattenIds<RenderProps>(arr: Node<RenderProps>[]): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
function flatten(arr: Node<RenderProps>[]) {
|
||||
for (let i = 0, len = arr.length; i < len; i++) {
|
||||
const item = arr[i];
|
||||
result.push(item.id);
|
||||
if (Array.isArray(item.children)) {
|
||||
flatten(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(arr);
|
||||
return result;
|
||||
}
|
@ -197,5 +197,8 @@
|
||||
"Pivots": "Pivots",
|
||||
"Add a subpage inside": "Add a subpage inside",
|
||||
"Rename": "Rename",
|
||||
"Move to": "Move to"
|
||||
"Move to": "Move to",
|
||||
"Move page to...": "Move page to...",
|
||||
"Remove from Pivots": "Remove from Pivots",
|
||||
"RFP": "Pages can be freely added/removed from pivots, remaining accessible from \"All Pages\"."
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user