mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { DocViewIcon, BoardViewIcon } from '@toeverything/components/icons';
|
||||
import { DocMode } from './type';
|
||||
|
||||
interface StatusIconProps {
|
||||
mode: DocMode;
|
||||
}
|
||||
|
||||
export const StatusIcon = ({ mode }: StatusIconProps) => {
|
||||
return (
|
||||
<IconWrapper mode={mode}>
|
||||
{mode === DocMode.doc ? <DocViewIcon /> : <BoardViewIcon />}
|
||||
</IconWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const IconWrapper = styled('div')<Pick<StatusIconProps, 'mode'>>(
|
||||
({ theme, mode }) => {
|
||||
return {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '5px',
|
||||
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
|
||||
color: theme.affine.palette.primary,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: theme.affine.palette.white,
|
||||
transform: `translateX(${mode === DocMode.doc ? 0 : 33}px)`,
|
||||
transition: 'transform 300ms ease',
|
||||
|
||||
'& > svg': {
|
||||
fontSize: '20px',
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type StatusTextProps = {
|
||||
children: string;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const StatusText = ({ children, active, onClick }: StatusTextProps) => {
|
||||
return (
|
||||
<StyledText active={active} onClick={onClick}>
|
||||
{children}
|
||||
</StyledText>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledText = styled('div')<StatusTextProps>(({ theme, active }) => {
|
||||
return {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: theme.affine.palette.primary,
|
||||
fontWeight: active ? '500' : '300',
|
||||
fontSize: '15px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 6px',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FC } from 'react';
|
||||
import type { DocMode } from './type';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { StatusIcon } from './StatusIcon';
|
||||
|
||||
interface StatusTrackProps {
|
||||
mode: DocMode;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const StatusTrack: FC<StatusTrackProps> = ({ mode, onClick }) => {
|
||||
return (
|
||||
<Container onClick={onClick}>
|
||||
<StatusIcon mode={mode} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')(({ theme }) => {
|
||||
return {
|
||||
backgroundColor: theme.affine.palette.textHover,
|
||||
borderRadius: '5px',
|
||||
height: '30px',
|
||||
width: '63px',
|
||||
cursor: 'pointer',
|
||||
padding: '5px',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { StatusText } from './StatusText';
|
||||
import { StatusTrack } from './StatusTrack';
|
||||
import { DocMode } from './type';
|
||||
|
||||
const isBoard = (pathname: string): boolean => pathname.endsWith('/whiteboard');
|
||||
|
||||
export const Switcher = () => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const pageViewMode = isBoard(pathname) ? DocMode.board : DocMode.doc;
|
||||
|
||||
const switchToPageView = (targetViewMode: DocMode) => {
|
||||
if (targetViewMode === pageViewMode) {
|
||||
return;
|
||||
}
|
||||
const workspaceId = params['workspace_id'];
|
||||
/**
|
||||
* There are two possible modes:
|
||||
* Page mode: /{workspaceId}/{pageId}
|
||||
* Board mode: /{workspaceId}/{pageId}/whiteboard
|
||||
*/
|
||||
const pageId = params['*'].split('/')[0];
|
||||
const targetUrl = `/${workspaceId}/${pageId}${
|
||||
targetViewMode === DocMode.board ? '/whiteboard' : ''
|
||||
}`;
|
||||
navigate(targetUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainerForSwitcher>
|
||||
<StatusText
|
||||
active={pageViewMode === DocMode.doc}
|
||||
onClick={() => switchToPageView(DocMode.doc)}
|
||||
>
|
||||
Doc
|
||||
</StatusText>
|
||||
<StatusTrack
|
||||
mode={pageViewMode}
|
||||
onClick={() => {
|
||||
switchToPageView(
|
||||
pageViewMode === DocMode.board
|
||||
? DocMode.doc
|
||||
: DocMode.board
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<StatusText
|
||||
active={pageViewMode === DocMode.board}
|
||||
onClick={() => switchToPageView(DocMode.board)}
|
||||
>
|
||||
Board
|
||||
</StatusText>
|
||||
</StyledContainerForSwitcher>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForSwitcher = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { Switcher as EditorBoardSwitcher } from './Switcher';
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum DocMode {
|
||||
// Page Mode
|
||||
doc,
|
||||
// Board Mode
|
||||
board,
|
||||
}
|
||||
184
libs/components/layout/src/header/Header.tsx
Normal file
184
libs/components/layout/src/header/Header.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import MenuIconBak from '@mui/icons-material/Menu';
|
||||
import {
|
||||
useUserAndSpaces,
|
||||
useShowSpaceSidebar,
|
||||
useShowSettingsSidebar,
|
||||
} from '@toeverything/datasource/state';
|
||||
import {
|
||||
MuiIconButton as IconButton,
|
||||
styled,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from '@toeverything/components/ui';
|
||||
import { SideBarViewIcon } from '@toeverything/components/icons';
|
||||
import { LogoLink, ListIcon, SpaceIcon } from '@toeverything/components/common';
|
||||
import { PageHistoryPortal } from './PageHistoryPortal';
|
||||
import { PageSharePortal } from './PageSharePortal';
|
||||
import { PageSettingPortal } from './PageSettingPortal';
|
||||
import { CurrentPageTitle } from './Title';
|
||||
import { UserMenuIcon } from './user-menu-icon';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
|
||||
function hideAffineHeader(pathname: string): boolean {
|
||||
return ['/', '/login', '/error/workspace', '/error/404', '/ui'].includes(
|
||||
pathname
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderIconProps = {
|
||||
isWhiteboardView?: boolean;
|
||||
};
|
||||
|
||||
export const AffineHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const { toggleSpaceSidebar, setSpaceSidebarVisible } =
|
||||
useShowSpaceSidebar();
|
||||
const { toggleSettingsSidebar: toggleInfoSidebar } =
|
||||
useShowSettingsSidebar();
|
||||
const theme = useTheme();
|
||||
const isWhiteboardView = pathname.endsWith('/whiteboard');
|
||||
const pageHistoryPortalFlag = useFlag('BooleanPageHistoryPortal', false);
|
||||
const pageSettingPortalFlag = useFlag('PageSettingPortal', false);
|
||||
const BooleanPageSharePortal = useFlag('BooleanPageSharePortal', false);
|
||||
// TODO: remove this
|
||||
useUserAndSpaces();
|
||||
|
||||
const showCenterTab =
|
||||
(params['workspace_id'] || pathname.includes('/space/')) && params['*'];
|
||||
|
||||
if (hideAffineHeader(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledHeader style={{ zIndex: theme.affine.zIndex.header }}>
|
||||
<StyledHeaderLeft>
|
||||
<LogoLink />  
|
||||
{/* <HeaderIcon
|
||||
onClick={toggleSpaceSidebar}
|
||||
onMouseEnter={() => setSpaceSidebarVisible(true)}
|
||||
sx={{ mr: 4, ml: 4 }}
|
||||
>
|
||||
<MenuIconBak />
|
||||
</HeaderIcon> */}
|
||||
{pageHistoryPortalFlag && (
|
||||
<HeaderIcon sx={{ mr: 4 }}>
|
||||
<PageHistoryPortal />
|
||||
</HeaderIcon>
|
||||
)}
|
||||
<CurrentPageTitle />
|
||||
</StyledHeaderLeft>
|
||||
|
||||
<StyledHeaderCenter>
|
||||
{showCenterTab && (
|
||||
<>
|
||||
<Tooltip content="Doc">
|
||||
<HeaderIcon
|
||||
style={{ width: '80px' }}
|
||||
isWhiteboardView={!isWhiteboardView}
|
||||
onClick={() =>
|
||||
isWhiteboardView
|
||||
? navigate(
|
||||
`/${
|
||||
params['workspace_id'] ||
|
||||
'space'
|
||||
}/${params['*'].slice(0, -11)}`
|
||||
)
|
||||
: null
|
||||
}
|
||||
>
|
||||
<ListIcon />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
marginLeft: '7.5px',
|
||||
}}
|
||||
>
|
||||
Doc
|
||||
</span>
|
||||
</HeaderIcon>
|
||||
</Tooltip>
|
||||
<Tooltip content="Whiteboard">
|
||||
<HeaderIcon
|
||||
isWhiteboardView={isWhiteboardView}
|
||||
onClick={() =>
|
||||
isWhiteboardView
|
||||
? null
|
||||
: navigate(
|
||||
`/${
|
||||
params['workspace_id'] ||
|
||||
'space'
|
||||
}/${params['*']}` + '/whiteboard'
|
||||
)
|
||||
}
|
||||
>
|
||||
<SpaceIcon />
|
||||
</HeaderIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</StyledHeaderCenter>
|
||||
<StyledHeaderRight>
|
||||
{BooleanPageSharePortal && (
|
||||
<HeaderIcon sx={{ mr: 4 }}>
|
||||
<PageSharePortal />
|
||||
</HeaderIcon>
|
||||
)}
|
||||
<UserMenuIcon />
|
||||
|
||||
{pageSettingPortalFlag && (
|
||||
<HeaderIcon>
|
||||
<PageSettingPortal />
|
||||
</HeaderIcon>
|
||||
)}
|
||||
|
||||
<HeaderIcon sx={{ mr: 4 }} onClick={toggleInfoSidebar}>
|
||||
<SideBarViewIcon />
|
||||
</HeaderIcon>
|
||||
</StyledHeaderRight>
|
||||
</StyledHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledHeader = styled('div')`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
padding: 0 32px;
|
||||
background-color: #fff;
|
||||
`;
|
||||
|
||||
const StyledHeaderLeft = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledHeaderCenter = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
const StyledHeaderRight = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const HeaderIcon = styled(IconButton, {
|
||||
shouldForwardProp: (prop: string) => prop !== 'isWhiteboardView',
|
||||
})<HeaderIconProps>(({ isWhiteboardView = false }) => ({
|
||||
color: '#98ACBD',
|
||||
minWidth: 48,
|
||||
width: 48,
|
||||
height: 36,
|
||||
borderRadius: '8px',
|
||||
...(isWhiteboardView && {
|
||||
color: '#fff',
|
||||
backgroundColor: '#3E6FDB',
|
||||
'&:hover': {
|
||||
backgroundColor: '#3E6FDB',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
81
libs/components/layout/src/header/LayoutHeader.tsx
Normal file
81
libs/components/layout/src/header/LayoutHeader.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { IconButton, styled } from '@toeverything/components/ui';
|
||||
import { LogoIcon, SideBarViewIcon } from '@toeverything/components/icons';
|
||||
import { useShowSettingsSidebar } from '@toeverything/datasource/state';
|
||||
import { CurrentPageTitle } from './Title';
|
||||
import { EditorBoardSwitcher } from './EditorBoardSwitcher';
|
||||
|
||||
export const LayoutHeader = () => {
|
||||
const { toggleSettingsSidebar: toggleInfoSidebar } =
|
||||
useShowSettingsSidebar();
|
||||
|
||||
return (
|
||||
<StyledContainerForHeaderRoot>
|
||||
<StyledHeaderRoot>
|
||||
<FlexContainer>
|
||||
<StyledLogoIcon />
|
||||
<TitleContainer>
|
||||
<CurrentPageTitle />
|
||||
</TitleContainer>
|
||||
</FlexContainer>
|
||||
<FlexContainer>
|
||||
<IconButton onClick={toggleInfoSidebar}>
|
||||
<SideBarViewIcon />
|
||||
</IconButton>
|
||||
</FlexContainer>
|
||||
<StyledContainerForEditorBoardSwitcher>
|
||||
<EditorBoardSwitcher />
|
||||
</StyledContainerForEditorBoardSwitcher>
|
||||
</StyledHeaderRoot>
|
||||
</StyledContainerForHeaderRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForHeaderRoot = styled('div')(({ theme }) => {
|
||||
return {
|
||||
width: '100%',
|
||||
zIndex: theme.affine.zIndex.header,
|
||||
};
|
||||
});
|
||||
|
||||
const StyledHeaderRoot = styled('div')(({ theme }) => {
|
||||
return {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: 60,
|
||||
marginLeft: 36,
|
||||
marginRight: 36,
|
||||
};
|
||||
});
|
||||
|
||||
const FlexContainer = styled('div')({ display: 'flex' });
|
||||
|
||||
const TitleContainer = styled('div')(({ theme }) => {
|
||||
return {
|
||||
// marginLeft: theme.affine.spacing.lgSpacing,
|
||||
marginLeft: 100,
|
||||
maxWidth: 500,
|
||||
overflowX: 'hidden',
|
||||
color: theme.affine.palette.primaryText,
|
||||
lineHeight: '18px',
|
||||
fontSize: '15px',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '1.06px',
|
||||
};
|
||||
});
|
||||
|
||||
const StyledLogoIcon = styled(LogoIcon)(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.primary,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
});
|
||||
|
||||
const StyledContainerForEditorBoardSwitcher = styled('div')(({ theme }) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
};
|
||||
});
|
||||
6
libs/components/layout/src/header/PageHistoryPortal.tsx
Normal file
6
libs/components/layout/src/header/PageHistoryPortal.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ClockIcon } from '@toeverything/components/common';
|
||||
function PageHistoryPortal() {
|
||||
return <ClockIcon />;
|
||||
}
|
||||
|
||||
export { PageHistoryPortal };
|
||||
267
libs/components/layout/src/header/PageSettingPortal.tsx
Normal file
267
libs/components/layout/src/header/PageSettingPortal.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, MouseEvent, useCallback, useEffect } from 'react';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import { MoreIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
MuiSnackbar as Snackbar,
|
||||
Popover,
|
||||
ListButton,
|
||||
MuiDivider as Divider,
|
||||
MuiSwitch as Switch,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import format from 'date-fns/format';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
import { PageBlock } from './types';
|
||||
import { FileExporter } from './file-exporter/file-exporter';
|
||||
const PageSettingPortalContainer = styled('div')({
|
||||
width: '320p',
|
||||
padding: '15px',
|
||||
'.textDescription': {
|
||||
height: '22px',
|
||||
lineHeight: '22px',
|
||||
marginLeft: '30px',
|
||||
fontSize: '14px',
|
||||
color: '#ccc',
|
||||
p: {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
'.switchDescription': {
|
||||
color: '#ccc',
|
||||
fontSize: '14px',
|
||||
paddingLeft: '30px',
|
||||
},
|
||||
});
|
||||
|
||||
const BtnPageSettingContainer = styled('div')({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
});
|
||||
|
||||
const MESSAGES = {
|
||||
COPY_LINK: ' Copy Link',
|
||||
INVITE: 'Add people,emails, or groups',
|
||||
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
|
||||
};
|
||||
|
||||
function PageSettingPortal() {
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const { workspace_id } = useParams();
|
||||
const [pageBlock, setPageBlock] = useState<PageBlock>();
|
||||
|
||||
const params = useParams();
|
||||
const pageId = params['*'].split('/')[0];
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserAndSpaces();
|
||||
const BooleanFullWidthChecked = useFlag('BooleanFullWidthChecked', false);
|
||||
const BooleanExportWorkspace = useFlag('BooleanExportWorkspace', false);
|
||||
const BooleanImportWorkspace = useFlag('BooleanImportWorkspace', false);
|
||||
const BooleanExportHtml = useFlag('BooleanExportHtml', false);
|
||||
const BooleanExportPdf = useFlag('BooleanExportPdf', false);
|
||||
const BooleanExportMarkdown = useFlag('BooleanExportMarkdown', false);
|
||||
|
||||
const fetchPageBlock = useCallback(async () => {
|
||||
const dbPageBlock = await services.api.editorBlock.getBlock(
|
||||
workspace_id,
|
||||
pageId
|
||||
);
|
||||
if (!dbPageBlock) return;
|
||||
const text = dbPageBlock.getDecoration('text');
|
||||
setPageBlock({
|
||||
lastUpdated: dbPageBlock.lastUpdated,
|
||||
fullWidthChecked:
|
||||
dbPageBlock.getDecoration('fullWidthChecked') || false,
|
||||
title:
|
||||
text &&
|
||||
//@ts-ignore
|
||||
text.value[0].text,
|
||||
});
|
||||
}, [workspace_id, pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPageBlock();
|
||||
}, [workspace_id, pageId, fetchPageBlock]);
|
||||
const redirectToPage = (newWorkspaceId: string, newPageId: string) => {
|
||||
navigate('/' + newWorkspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
//create page
|
||||
const newPage = await services.api.editorBlock.create({
|
||||
workspace: workspace_id,
|
||||
type: 'page' as const,
|
||||
});
|
||||
//add page to tree
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspace_id,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
//copy source page to new page
|
||||
await services.api.editorBlock.copyPage(
|
||||
workspace_id,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
|
||||
redirectToPage(workspace_id, newPage.id);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(window.location.href);
|
||||
setAlertOpen(true);
|
||||
};
|
||||
const handleAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
};
|
||||
|
||||
const handleExportWorkspace = () => {
|
||||
//@ts-ignore
|
||||
window.client.inspector().save();
|
||||
};
|
||||
|
||||
const handleImportWorkspace = () => {
|
||||
//@ts-ignore
|
||||
window.client
|
||||
.inspector()
|
||||
.load()
|
||||
.then(() => {
|
||||
window.location.href = `/${workspace_id}/`;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFullWidthCheckedChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const checked = event.target.checked;
|
||||
setPageBlock({
|
||||
lastUpdated: pageBlock.lastUpdated,
|
||||
fullWidthChecked: checked,
|
||||
});
|
||||
services.api.editorBlock.update({
|
||||
properties: {
|
||||
fullWidthChecked: checked,
|
||||
},
|
||||
id: pageId,
|
||||
workspace: workspace_id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportHtml = async () => {
|
||||
//@ts-ignore
|
||||
const htmlContent = await virgo.clipboard
|
||||
.getClipboardParse()
|
||||
.page2html();
|
||||
const htmlTitle = pageBlock.title;
|
||||
|
||||
FileExporter.exportHtml(htmlTitle, htmlContent);
|
||||
};
|
||||
|
||||
const handleExportMarkdown = async () => {
|
||||
//@ts-ignore
|
||||
const htmlContent = await virgo.clipboard
|
||||
.getClipboardParse()
|
||||
.page2html();
|
||||
const htmlTitle = pageBlock.title;
|
||||
FileExporter.exportMarkdown(htmlTitle, htmlContent);
|
||||
};
|
||||
return (
|
||||
<BtnPageSettingContainer>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
content={
|
||||
<PageSettingPortalContainer>
|
||||
{BooleanFullWidthChecked && (
|
||||
<>
|
||||
<div className="switchDescription">
|
||||
Full width
|
||||
<Switch
|
||||
checked={
|
||||
pageBlock &&
|
||||
pageBlock.fullWidthChecked
|
||||
}
|
||||
onChange={handleFullWidthCheckedChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListButton
|
||||
content="Duplicate Page"
|
||||
onClick={handleDuplicatePage}
|
||||
/>
|
||||
<ListButton
|
||||
content={MESSAGES.COPY_LINK}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<Divider />
|
||||
{BooleanExportMarkdown && (
|
||||
<ListButton
|
||||
content="Export As Markdown"
|
||||
onClick={handleExportMarkdown}
|
||||
/>
|
||||
)}
|
||||
{BooleanExportHtml && (
|
||||
<ListButton
|
||||
content="Export As HTML"
|
||||
onClick={handleExportHtml}
|
||||
/>
|
||||
)}
|
||||
{BooleanExportPdf && (
|
||||
<ListButton
|
||||
content="Export As PDF"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
{BooleanImportWorkspace && (
|
||||
<ListButton
|
||||
content="Import Workspace"
|
||||
onClick={handleImportWorkspace}
|
||||
/>
|
||||
)}
|
||||
{BooleanExportWorkspace && (
|
||||
<ListButton
|
||||
content="Export Workspace"
|
||||
onClick={handleExportWorkspace}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="textDescription">
|
||||
Last edited by {user && user.nickname}
|
||||
<br />
|
||||
{pageBlock &&
|
||||
`${format(
|
||||
new Date(pageBlock.lastUpdated),
|
||||
'MM/dd/yyyy hh:mm'
|
||||
)}`}
|
||||
</p>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={alertOpen}
|
||||
message={MESSAGES.COPY_LINK_SUCCESS}
|
||||
key={'bottomcenter'}
|
||||
autoHideDuration={2000}
|
||||
onClose={handleAlertClose}
|
||||
/>
|
||||
</PageSettingPortalContainer>
|
||||
}
|
||||
>
|
||||
<MoreIcon />
|
||||
</Popover>
|
||||
</BtnPageSettingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export { PageSettingPortal };
|
||||
146
libs/components/layout/src/header/PageSharePortal.tsx
Normal file
146
libs/components/layout/src/header/PageSharePortal.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import style9 from 'style9';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import { useState, MouseEvent } from 'react';
|
||||
import InsertLinkIcon from '@mui/icons-material/InsertLink';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import {
|
||||
MuiSnackbar as Snackbar,
|
||||
MuiButton as Button,
|
||||
MuiDivider as Divider,
|
||||
MuiInputBase as InputBase,
|
||||
MuiPaper as Paper,
|
||||
MuiSwitch as Switch,
|
||||
Popover,
|
||||
} from '@toeverything/components/ui';
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
|
||||
const styles = style9.create({
|
||||
pageShareBox: {
|
||||
width: '500px',
|
||||
padding: '30px 20px 50px 20px',
|
||||
},
|
||||
btnPageShare: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
},
|
||||
pageShareBoxIcon: {
|
||||
position: 'absolute',
|
||||
left: '5px',
|
||||
top: '10px',
|
||||
color: 'var(--color-gray-400)',
|
||||
},
|
||||
pageShareBoxForWeb: {
|
||||
position: 'relative',
|
||||
paddingLeft: '40px',
|
||||
borderBottom: '1px solid var(--color-gray-400))',
|
||||
marginBottom: '15px',
|
||||
},
|
||||
pageShareBoxSwitch: {
|
||||
position: 'absolute',
|
||||
right: '5px',
|
||||
top: '10px',
|
||||
},
|
||||
pageShareBoxForWebP1: {
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '5px',
|
||||
},
|
||||
pageShareBoxForWebP2: {
|
||||
color: 'var(--color-gray-400)',
|
||||
},
|
||||
copyLinkBtn: {
|
||||
marginTop: '15px',
|
||||
float: 'right',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
copyLinkcon: {
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
});
|
||||
const MESSAGES = {
|
||||
COPY_LINK: ' Copy Link',
|
||||
INVITE: 'Add people,emails, or groups',
|
||||
SHARE_TO_WEB: 'Share to web',
|
||||
SHARE_TO_ANYONE: 'Publish and share link with any one',
|
||||
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
|
||||
};
|
||||
function PageSharePortal() {
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(window.location.href);
|
||||
setAlertOpen(true);
|
||||
};
|
||||
const handleAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
trigger="click"
|
||||
content={
|
||||
<div className={styles('pageShareBox')}>
|
||||
<div className={styles('pageShareBoxForWeb')}>
|
||||
<LanguageIcon
|
||||
className={styles('pageShareBoxIcon')}
|
||||
/>
|
||||
<p className={styles('pageShareBoxForWebP1')}>
|
||||
{MESSAGES.SHARE_TO_WEB}
|
||||
</p>
|
||||
<p className={styles('pageShareBoxForWebP2')}>
|
||||
{MESSAGES.SHARE_TO_ANYONE}
|
||||
</p>
|
||||
<div className={styles('pageShareBoxSwitch')}>
|
||||
<Switch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Paper
|
||||
component="form"
|
||||
sx={{
|
||||
p: '0px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 460,
|
||||
}}
|
||||
>
|
||||
<InputBase
|
||||
sx={{ ml: 1, flex: 1 }}
|
||||
placeholder={MESSAGES.INVITE}
|
||||
/>
|
||||
<Button variant="contained">Invite</Button>
|
||||
</Paper>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className={styles('copyLinkBtn')}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<InsertLinkIcon
|
||||
className={styles('copyLinkcon')}
|
||||
/>
|
||||
{MESSAGES.COPY_LINK}
|
||||
</a>
|
||||
</div>
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={alertOpen}
|
||||
message={MESSAGES.COPY_LINK_SUCCESS}
|
||||
key={'bottomcenter'}
|
||||
autoHideDuration={2000}
|
||||
onClose={handleAlertClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ShareIcon style={{ marginTop: '8px' }} />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PageSharePortal };
|
||||
80
libs/components/layout/src/header/Tempnav.tsx
Normal file
80
libs/components/layout/src/header/Tempnav.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
MuiButton as Button,
|
||||
MuiMenu as Menu,
|
||||
MuiMenuItem as MenuItem,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
export function TempLinkRouter() {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={e => setAnchorEl(e.currentTarget)} sx={{ mr: 4 }}>
|
||||
Debug Routers
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<NavLink to="/agenda/calendar">/agenda/calendar</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<NavLink to="/agenda/tasks">/agenda/tasks</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<NavLink to="/agenda/today">/agenda/today</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<NavLink to="/agenda/">/agenda/</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/workspaceId/labels">
|
||||
/workspaceId/labels
|
||||
</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/workspaceId/pages">
|
||||
/workspaceId/pages
|
||||
</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/workspaceId/docId">
|
||||
/workspaceId/docId
|
||||
</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/workspaceId/">/workspaceId/</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/login">/login</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/recent">/recent</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/search">/search</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/settings">/settings</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/shared">/shared</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/started">/started</NavLink>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<NavLink to="/">/</NavLink>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TempLinkRouter;
|
||||
85
libs/components/layout/src/header/Title.tsx
Normal file
85
libs/components/layout/src/header/Title.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Typography, styled } from '@toeverything/components/ui';
|
||||
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
|
||||
/* card.7: Demand changes, temporarily closed, see https://github.com/toeverything/AFFiNE/issues/522 */
|
||||
// import { usePageTree} from '@toeverything/components/layout';
|
||||
// import { pickPath } from './utils';
|
||||
|
||||
export const CurrentPageTitle = () => {
|
||||
const params = useParams();
|
||||
const { workspace_id } = params;
|
||||
const [pageId, setPageId] = useState<string>('');
|
||||
const [pageTitle, setPageTitle] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (params['*']) {
|
||||
setPageId(params['*'].split('/')[0]);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const fetchPageTitle = useCallback(async () => {
|
||||
if (!workspace_id || !pageId) return;
|
||||
const [pageEditorBlock] = await services.api.editorBlock.get({
|
||||
workspace: workspace_id,
|
||||
ids: [pageId],
|
||||
});
|
||||
/* card.7 */
|
||||
/* If the id is unique, only one path will be matched */
|
||||
// const routes = pickPath(items, pageId).filter(item => item.length)?.[0];
|
||||
|
||||
setPageTitle(
|
||||
pageEditorBlock?.properties?.text?.value
|
||||
?.map(v => v.text)
|
||||
.join('') ?? 'Untitled'
|
||||
);
|
||||
}, [pageId, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPageTitle();
|
||||
}, [fetchPageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace_id || !pageId || pageTitle === undefined)
|
||||
return () => {};
|
||||
|
||||
let unobserve: () => void;
|
||||
const auto_update_title = async () => {
|
||||
// console.log(';; title registration auto update');
|
||||
unobserve = await services.api.editorBlock.observe(
|
||||
{ workspace: workspace_id, id: pageId },
|
||||
businessBlock => {
|
||||
// console.log(';; auto_update_title', businessBlock);
|
||||
fetchPageTitle();
|
||||
}
|
||||
);
|
||||
};
|
||||
auto_update_title();
|
||||
|
||||
return () => {
|
||||
// unobserve?.();
|
||||
};
|
||||
}, [fetchPageTitle, pageId, pageTitle, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = pageTitle || '';
|
||||
}, [pageTitle]);
|
||||
|
||||
return pageTitle ? (
|
||||
<ContentText type="sm" title={pageTitle}>
|
||||
{pageTitle}
|
||||
</ContentText>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const ContentText = styled(Typography)(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.primaryText,
|
||||
maxWidth: '240px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PageBlock } from '../types';
|
||||
import TurndownService from 'turndown';
|
||||
const FileExporter = {
|
||||
exportFile: (filename: string, text: string, format: string) => {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute(
|
||||
'href',
|
||||
'data:' + format + ';charset=utf-8,' + encodeURIComponent(text)
|
||||
);
|
||||
element.setAttribute('download', filename);
|
||||
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
},
|
||||
decorateHtml: (pageTitle: string, htmlContent: string) => {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${pageTitle}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="margin:20px auto;width:800px" >
|
||||
<div style="background-color: #fff;box-shadow: 0px 0px 5px #ccc;padding: 10px;">
|
||||
${htmlContent}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
},
|
||||
decoreateAffineBrand: (pageTitle: string) => {
|
||||
return pageTitle + ` Created in Affine`;
|
||||
},
|
||||
exportHtml: (pageTitle: string, htmlContent: string) => {
|
||||
FileExporter.exportFile(
|
||||
FileExporter.decoreateAffineBrand(pageTitle) + '.html',
|
||||
FileExporter.decorateHtml(pageTitle, htmlContent),
|
||||
'text/html'
|
||||
);
|
||||
},
|
||||
|
||||
exportMarkdown: (pageTitle: string, htmlContent: string) => {
|
||||
const turndownService = new TurndownService();
|
||||
const markdown = turndownService.turndown(htmlContent);
|
||||
|
||||
FileExporter.exportFile(
|
||||
FileExporter.decoreateAffineBrand(pageTitle) + '.md',
|
||||
markdown,
|
||||
'text/plain'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { FileExporter };
|
||||
2
libs/components/layout/src/header/index.ts
Normal file
2
libs/components/layout/src/header/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AffineHeader } from './Header';
|
||||
export { LayoutHeader } from './LayoutHeader';
|
||||
7
libs/components/layout/src/header/types.ts
Normal file
7
libs/components/layout/src/header/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
interface PageBlock {
|
||||
lastUpdated?: number;
|
||||
fullWidthChecked?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export { PageBlock };
|
||||
13
libs/components/layout/src/header/use-layout-header.ts
Normal file
13
libs/components/layout/src/header/use-layout-header.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type PageViewStatusType = 'editor' | 'board';
|
||||
|
||||
export const useLayoutHeader = () => {
|
||||
const [pageViewStatus, setPageViewStatus] =
|
||||
useState<PageViewStatusType>('editor');
|
||||
|
||||
return {
|
||||
pageViewStatus,
|
||||
setPageViewStatus,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MuiAvatar as Avatar, Popover } from '@toeverything/components/ui';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { getUserDisplayName } from '@toeverything/utils';
|
||||
import { UserMenuList } from './UserMenuList';
|
||||
|
||||
/**
|
||||
* An icon is displayed by default, click the icon to display the popover, and the content of the popover is children.
|
||||
*/
|
||||
export const UserMenuIcon = () => {
|
||||
const { user } = useUserAndSpaces();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={<UserMenuList />}
|
||||
placement="bottom-end"
|
||||
trigger="click"
|
||||
>
|
||||
<Avatar
|
||||
// onClick={handleClick}
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
mr: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
alt={getUserDisplayName(user)}
|
||||
src={user?.photo || ''}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenuIcon;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from 'react';
|
||||
import { getAuth, signOut } from 'firebase/auth';
|
||||
|
||||
import { LOGOUT_COOKIES, LOGOUT_LOCAL_STORAGE } from '@toeverything/utils';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import {
|
||||
MuiDivider as Divider,
|
||||
MuiList as List,
|
||||
MuiListItem as ListItem,
|
||||
MuiListItemText as ListItemText,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
export const UserMenuList = () => {
|
||||
const { user } = useUserAndSpaces();
|
||||
|
||||
const user_menu_data = useMemo(() => {
|
||||
return [
|
||||
// {
|
||||
// text: 'Settings',
|
||||
// onClick: () => {
|
||||
// console.log('Open the settings panel');
|
||||
// },
|
||||
// },
|
||||
{
|
||||
text: user?.email || 'Unknown User',
|
||||
showDivider: true,
|
||||
},
|
||||
{
|
||||
text: 'Logout',
|
||||
onClick: async () => {
|
||||
LOGOUT_LOCAL_STORAGE.forEach(name =>
|
||||
localStorage.removeItem(name)
|
||||
);
|
||||
// localStorage.clear();
|
||||
document.cookie = LOGOUT_COOKIES.map(
|
||||
name =>
|
||||
name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
|
||||
).join(' ');
|
||||
|
||||
signOut(getAuth());
|
||||
|
||||
window.location.href = '/';
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [user?.email]);
|
||||
|
||||
return (
|
||||
<List component="nav" aria-label="user settings">
|
||||
{user_menu_data.map(menu => {
|
||||
const { text, onClick, showDivider } = menu;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem button onClick={onClick} key={text}>
|
||||
<ListItemText primary={text} />
|
||||
</ListItem>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenuList;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { UserMenuIcon } from './UserMenuIcon';
|
||||
export { UserMenuIcon };
|
||||
export default UserMenuIcon;
|
||||
3
libs/components/layout/src/index.ts
Normal file
3
libs/components/layout/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './header';
|
||||
export * from './settings-sidebar';
|
||||
export * from './workspace-sidebar';
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { styled, MuiClickAwayListener } from '@toeverything/components/ui';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
|
||||
import { QuotedContent } from './item/QuotedContent';
|
||||
import { ReplyItem } from './item/ReplyItem';
|
||||
import { ReplyInput } from './item/ReplyInput';
|
||||
import { CommentInfo } from './type';
|
||||
|
||||
export const CommentItem = (props: CommentInfo) => {
|
||||
const {
|
||||
id,
|
||||
workspace,
|
||||
attachedToBlocksIds,
|
||||
quote,
|
||||
replyList,
|
||||
resolve,
|
||||
activeCommentId,
|
||||
resolveComment,
|
||||
} = props;
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
const handleSubmitComment = useCallback(
|
||||
async (value: string) => {
|
||||
await services.api.commentService.createReply({
|
||||
workspace,
|
||||
parentId: id,
|
||||
content: { value: [{ text: value }] },
|
||||
});
|
||||
},
|
||||
[id, workspace]
|
||||
);
|
||||
|
||||
const handleToggleResolveComment = useCallback(async () => {
|
||||
resolveComment(attachedToBlocksIds[0], id);
|
||||
await services.api.commentService.updateComment({
|
||||
workspace,
|
||||
id,
|
||||
attachedToBlocksIds,
|
||||
resolve: !resolve,
|
||||
});
|
||||
}, [attachedToBlocksIds, id, resolve, resolveComment, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCommentId === id) {
|
||||
setIsActive(true);
|
||||
} else {
|
||||
setIsActive(false);
|
||||
}
|
||||
}, [activeCommentId, id]);
|
||||
|
||||
return (
|
||||
<MuiClickAwayListener onClickAway={() => setIsActive(false)}>
|
||||
<StyledContainerForCommentItem
|
||||
isActive={isActive}
|
||||
onClick={() => setIsActive(true)}
|
||||
>
|
||||
<StyledItemContent>
|
||||
<QuotedContent
|
||||
content={quote.value[0].text}
|
||||
onToggle={handleToggleResolveComment}
|
||||
/>
|
||||
{replyList?.map((reply, index) => {
|
||||
if (index === replyList.length - 1) {
|
||||
return <ReplyItem {...reply} key={reply.id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={reply.id}>
|
||||
<ReplyItem {...reply} />
|
||||
<StyledReplySeparator />
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{isActive ? (
|
||||
<ReplyInput onSubmit={handleSubmitComment} />
|
||||
) : null}
|
||||
</StyledItemContent>
|
||||
</StyledContainerForCommentItem>
|
||||
</MuiClickAwayListener>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForCommentItem = styled('div', {
|
||||
shouldForwardProp: (prop: string) => !['isActive'].includes(prop),
|
||||
})<{ isActive?: boolean }>(({ theme, isActive }) => {
|
||||
return {
|
||||
position: 'relative',
|
||||
width: 322,
|
||||
border: `2px solid ${theme.affine.palette.menuSeparator}`,
|
||||
borderRadius: theme.affine.shape.borderRadius,
|
||||
marginBottom: theme.affine.spacing.smSpacing,
|
||||
left: isActive ? -58 : 0,
|
||||
transition: 'left 150ms ease-in-out',
|
||||
backgroundColor: theme.affine.palette.white,
|
||||
'&:hover': {
|
||||
boxShadow: theme.affine.shadows.shadowSxDownLg,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const StyledItemContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
marginLeft: theme.affine.spacing.main,
|
||||
marginRight: theme.affine.spacing.main,
|
||||
marginTop: theme.affine.spacing.smSpacing,
|
||||
marginBottom: theme.affine.spacing.smSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
const StyledReplySeparator = styled('div')(({ theme }) => {
|
||||
return {
|
||||
width: 290,
|
||||
height: 1,
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
color: theme.affine.palette.menuSeparator,
|
||||
backgroundColor: theme.affine.palette.menuSeparator,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useComments } from './use-comments';
|
||||
import { CommentItem } from './CommentItem';
|
||||
|
||||
type CommentsProps = {
|
||||
activeCommentId: string;
|
||||
resolveComment: (blockId: string, commentId: string) => void;
|
||||
};
|
||||
|
||||
export const Comments = ({
|
||||
activeCommentId,
|
||||
resolveComment,
|
||||
}: CommentsProps) => {
|
||||
const { comments } = useComments();
|
||||
|
||||
return (
|
||||
<StyledContainerForComments className="id-comments-panel">
|
||||
{comments?.map(comment => {
|
||||
return (
|
||||
<CommentItem
|
||||
{...comment}
|
||||
activeCommentId={activeCommentId}
|
||||
resolveComment={resolveComment}
|
||||
key={comment.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledContainerForComments>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForComments = styled('div')(({ theme }) => {
|
||||
return {
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { Comments } from './Comments';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type CommentContentProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const CommentContent = ({ content }: CommentContentProps) => {
|
||||
return (
|
||||
<StyledContainerForCommentContent>
|
||||
<p>{content || ''}</p>
|
||||
</StyledContainerForCommentContent>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForCommentContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
color: theme.affine.palette.primaryText,
|
||||
marginTop: theme.affine.spacing.xsSpacing,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
import { styled, MuiAvatar as Avatar } from '@toeverything/components/ui';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { getUserDisplayName } from '@toeverything/utils';
|
||||
|
||||
type CommentedByUserProps = {
|
||||
username: string;
|
||||
updateTime: number;
|
||||
};
|
||||
|
||||
export const CommentedByUser = ({
|
||||
username,
|
||||
updateTime: updatedTime,
|
||||
}: CommentedByUserProps) => {
|
||||
const updateDatetime = useMemo(() => new Date(updatedTime), [updatedTime]);
|
||||
//TODO temp
|
||||
const { user } = useUserAndSpaces();
|
||||
|
||||
return (
|
||||
<StyledContainerForCommentedByUser>
|
||||
<Avatar sx={{ bgcolor: '#9176FF' }} src={user?.photo || ''}>
|
||||
{/* {username ? username.slice(0, 2).toLocaleUpperCase() : ''} */}
|
||||
{getUserDisplayName(user)}
|
||||
</Avatar>
|
||||
<StyledCommentUserInfo>
|
||||
<div> {getUserDisplayName(user)}</div>
|
||||
<StyledCommentTime>
|
||||
{updateDatetime.toTimeString().slice(0, 5)}{' '}
|
||||
{updateDatetime.toDateString().slice(4, 10)}
|
||||
</StyledCommentTime>
|
||||
</StyledCommentUserInfo>
|
||||
</StyledContainerForCommentedByUser>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForCommentedByUser = styled('div')(({ theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
};
|
||||
});
|
||||
|
||||
const StyledCommentUserInfo = styled('div')(({ theme }) => {
|
||||
return {
|
||||
marginLeft: theme.affine.spacing.smSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
const StyledCommentTime = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.icons,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { DoneIcon } from '@toeverything/components/icons';
|
||||
|
||||
type QuotedContentProps = {
|
||||
content: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export const QuotedContent = ({ content, onToggle }: QuotedContentProps) => {
|
||||
return (
|
||||
<StyledContainerForQuotedContent>
|
||||
<StyledVerticalLine />
|
||||
<StyledQuotedContent>{content || ''}</StyledQuotedContent>
|
||||
<StyledResolveAction onClick={onToggle} fontSize="small" />
|
||||
</StyledContainerForQuotedContent>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForQuotedContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
// marginBottom: theme.affine.spacing.xsSpacing,
|
||||
marginBottom: 6,
|
||||
};
|
||||
});
|
||||
|
||||
const StyledVerticalLine = styled('div')(({ theme }) => {
|
||||
return {
|
||||
width: 2,
|
||||
height: 18,
|
||||
marginRight: theme.affine.spacing.smSpacing,
|
||||
backgroundColor: '#97EEF2',
|
||||
};
|
||||
});
|
||||
|
||||
const StyledQuotedContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.primaryText,
|
||||
flex: '1 1 0',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
};
|
||||
});
|
||||
|
||||
const StyledResolveAction = styled(DoneIcon)(({ theme }) => {
|
||||
return {
|
||||
marginLeft: theme.affine.spacing.xsSpacing,
|
||||
color: theme.affine.palette.primary,
|
||||
// fontSize: '1.2rem',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
KeyboardEventHandler,
|
||||
ChangeEvent,
|
||||
} from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export const ReplyInput = (props: any) => {
|
||||
const { onSubmit } = props;
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = e => {
|
||||
if (!e.metaKey && !e.shiftKey && e.code === 'Enter' && value) {
|
||||
onSubmit && onSubmit(value);
|
||||
setValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value.trim());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainerForReplyInput className="affine-comment-reply-input">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={'reply...'}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={handleInputChange}
|
||||
value={value}
|
||||
/>
|
||||
</StyledContainerForReplyInput>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForReplyInput = styled('div')(({ theme }) => {
|
||||
return {
|
||||
// marginTop: theme.affine.spacing.xsSpacing,
|
||||
marginTop: 8,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { CommentReply } from '@toeverything/datasource/db-service';
|
||||
|
||||
import { CommentContent } from './CommentContent';
|
||||
import { CommentedByUser } from './CommentedByUser';
|
||||
|
||||
export const ReplyItem = (props: CommentReply) => {
|
||||
const { creator, lastUpdated, content } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentedByUser username={creator} updateTime={lastUpdated} />
|
||||
<CommentContent content={content.value[0].text} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
10
libs/components/layout/src/settings-sidebar/Comments/type.ts
Normal file
10
libs/components/layout/src/settings-sidebar/Comments/type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {
|
||||
Comment,
|
||||
CommentReply,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
|
||||
export interface CommentInfo extends Comment {
|
||||
replyList?: CommentReply[];
|
||||
activeCommentId?: string;
|
||||
resolveComment: (blockId: string, commentId: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import type { Virgo } from '@toeverything/components/editor-core';
|
||||
import {
|
||||
useCurrentEditors,
|
||||
useShowSettingsSidebar,
|
||||
} from '@toeverything/datasource/state';
|
||||
import type { CommentInfo } from './type';
|
||||
|
||||
export const useComments = () => {
|
||||
const { workspace_id: workspaceId, page_id: pageId } = useParams();
|
||||
|
||||
const [comments, setComments] = useState<CommentInfo[]>([]);
|
||||
const [observeIds, setObserveIds] = useState<string[]>([]);
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
if (!workspaceId || !pageId) return;
|
||||
const ids = [];
|
||||
const pageComment = await services.api.commentService.getPageComments({
|
||||
workspace: workspaceId,
|
||||
pageId: pageId,
|
||||
});
|
||||
ids.push(pageComment.id);
|
||||
|
||||
let comments = await services.api.commentService.getComments({
|
||||
workspace: workspaceId,
|
||||
ids: pageComment?.children,
|
||||
});
|
||||
|
||||
comments = await Promise.all(
|
||||
comments.map(async comment => {
|
||||
const commentInfo = comment as CommentInfo;
|
||||
commentInfo.replyList =
|
||||
await services.api.commentService.getReplyList({
|
||||
workspace: workspaceId,
|
||||
ids: comment.children,
|
||||
});
|
||||
ids.push(comment.id);
|
||||
ids.push(...comment.children);
|
||||
return commentInfo;
|
||||
})
|
||||
);
|
||||
|
||||
setComments(comments.reverse() as CommentInfo[]);
|
||||
setObserveIds(ids);
|
||||
}, [pageId, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments();
|
||||
}, [fetchComments]);
|
||||
|
||||
// first simple implementation
|
||||
useEffect(() => {
|
||||
const unobserveList: any[] = [];
|
||||
observeIds.forEach(async id => {
|
||||
const unobserve = await services.api.editorBlock.observe(
|
||||
{ workspace: workspaceId, id: id },
|
||||
block => {
|
||||
fetchComments();
|
||||
}
|
||||
);
|
||||
unobserveList.push(unobserve);
|
||||
});
|
||||
return () => {
|
||||
unobserveList.forEach(unobserve => unobserve?.());
|
||||
};
|
||||
}, [fetchComments, workspaceId, observeIds]);
|
||||
|
||||
return { comments };
|
||||
};
|
||||
|
||||
export const useActiveComment = () => {
|
||||
const { workspace_id: workspaceId, page_id: pageId } = useParams();
|
||||
const { currentEditors } = useCurrentEditors();
|
||||
const editor = useMemo(() => {
|
||||
return currentEditors[pageId] as Virgo;
|
||||
}, [currentEditors, pageId]);
|
||||
|
||||
const [activeCommentId, setActiveCommentId] = useState('');
|
||||
|
||||
const { setShowSettingsSidebar: setShowInfoSidebar } =
|
||||
useShowSettingsSidebar();
|
||||
|
||||
const resolveComment = useCallback(
|
||||
(blockId: string, commentId: string) => {
|
||||
editor?.blockHelper.resolveComment(blockId, commentId);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.selection.onSelectEnd(info => {
|
||||
// TODO: only do the following when sidebar is open
|
||||
|
||||
const { type, anchorNode } = info;
|
||||
if (type === 'None' || !anchorNode) return;
|
||||
const currentSelectionInTextBlock =
|
||||
editor.blockHelper.getCurrentSelection(anchorNode.id);
|
||||
if (!currentSelectionInTextBlock) return;
|
||||
|
||||
// get possible commentId from selection
|
||||
let maybeActiveCommentsIds = [] as string[];
|
||||
|
||||
if (editor.blockHelper.isSelectionCollapsed(anchorNode.id)) {
|
||||
// TODO: search before/after for comment text node, improve this
|
||||
maybeActiveCommentsIds =
|
||||
editor.blockHelper.getCommentsIdsBySelection(anchorNode.id);
|
||||
} else {
|
||||
maybeActiveCommentsIds =
|
||||
editor.blockHelper.getCommentsIdsBySelection(anchorNode.id);
|
||||
}
|
||||
|
||||
// TODO: set the shortest comment as active comment instead of the first
|
||||
setActiveCommentId(
|
||||
maybeActiveCommentsIds.length ? maybeActiveCommentsIds[0] : ''
|
||||
);
|
||||
});
|
||||
}, [currentEditors, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCommentId) {
|
||||
setShowInfoSidebar(true);
|
||||
}
|
||||
}, [activeCommentId, setShowInfoSidebar]);
|
||||
|
||||
return { activeCommentId, resolveComment };
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {
|
||||
ReturnEditorBlock,
|
||||
Comment,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
|
||||
export const getCommentsFromEditorBlocks = (
|
||||
editorBlocks: ReturnEditorBlock[]
|
||||
) => {
|
||||
return [] as Comment[];
|
||||
};
|
||||
|
||||
export const getCommentReplyFromEditorBlock = (
|
||||
editorBlock: ReturnEditorBlock
|
||||
) => {
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { cloneElement, useMemo, useCallback, type ReactElement } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
CommentIcon,
|
||||
LayoutIcon,
|
||||
SettingsIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { LayoutSettings } from '../Layout';
|
||||
import { Comments } from '../Comments';
|
||||
import { useActiveComment } from '../Comments/use-comments';
|
||||
import { SettingsPanel } from '../Settings';
|
||||
import { TabItemTitle } from './TabItemTitle';
|
||||
import { useTabs } from './use-tabs';
|
||||
|
||||
const _defaultTabsKeys = ['layout', 'comment', 'settings'] as const;
|
||||
|
||||
export const ContainerTabs = () => {
|
||||
const { activeCommentId, resolveComment } = useActiveComment();
|
||||
|
||||
const getSettingsTabsData = useCallback((): SettingsTabItemType[] => {
|
||||
return [
|
||||
{
|
||||
type: 'layout',
|
||||
text: 'Layout',
|
||||
icon: (
|
||||
<IconWrapper>
|
||||
<LayoutIcon />
|
||||
</IconWrapper>
|
||||
),
|
||||
panel: <LayoutSettings />,
|
||||
},
|
||||
{
|
||||
type: 'comment',
|
||||
text: 'Comment',
|
||||
icon: (
|
||||
<IconWrapper>
|
||||
<CommentIcon />
|
||||
</IconWrapper>
|
||||
),
|
||||
panel: (
|
||||
<StyledSidebarContent>
|
||||
<Comments
|
||||
activeCommentId={activeCommentId}
|
||||
resolveComment={resolveComment}
|
||||
/>
|
||||
</StyledSidebarContent>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'settings',
|
||||
text: 'Settings',
|
||||
icon: (
|
||||
<IconWrapper>
|
||||
<SettingsIcon />
|
||||
</IconWrapper>
|
||||
),
|
||||
panel: <SettingsPanel />,
|
||||
},
|
||||
];
|
||||
}, [activeCommentId, resolveComment]);
|
||||
|
||||
const settingsTabsData = useMemo(() => {
|
||||
return getSettingsTabsData();
|
||||
}, [getSettingsTabsData]);
|
||||
|
||||
const [activeTab, setActiveTab] = useTabs(
|
||||
_defaultTabsKeys as unknown as string[],
|
||||
'settings'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTabsTitlesContainer>
|
||||
{settingsTabsData.map(tab => {
|
||||
const { type, text, icon } = tab;
|
||||
return (
|
||||
<TabItemTitle
|
||||
title={text}
|
||||
icon={icon}
|
||||
isActive={activeTab === type}
|
||||
onClick={() => {
|
||||
setActiveTab(type);
|
||||
}}
|
||||
key={type}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledTabsTitlesContainer>
|
||||
<StyledTabsPanelsContainer>
|
||||
{settingsTabsData.map(tab => {
|
||||
const { type, panel } = tab;
|
||||
return type === activeTab
|
||||
? cloneElement(panel, { key: type })
|
||||
: null;
|
||||
})}
|
||||
</StyledTabsPanelsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledTabsTitlesContainer = styled('div')(({ theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 24,
|
||||
marginBottom: 24,
|
||||
marginLeft: theme.affine.spacing.smSpacing,
|
||||
marginRight: theme.affine.spacing.smSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
const StyledTabsPanelsContainer = styled('div')(({ theme }) => {
|
||||
return {
|
||||
height: 'calc(100vh - 80px)',
|
||||
borderTop: `1px solid ${theme.affine.palette.tagHover}`,
|
||||
};
|
||||
});
|
||||
|
||||
type SettingsTabsTypes = typeof _defaultTabsKeys[number];
|
||||
|
||||
type SettingsTabItemType = {
|
||||
type: SettingsTabsTypes;
|
||||
text: string;
|
||||
icon: ReactElement;
|
||||
panel: ReactElement;
|
||||
};
|
||||
|
||||
const StyledSidebarContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
marginLeft: theme.affine.spacing.lgSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
const IconWrapper = styled('div')({
|
||||
fontSize: 0,
|
||||
|
||||
'& > svg': {
|
||||
fontSize: '20px',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { styled, ListItem, ListIcon } from '@toeverything/components/ui';
|
||||
|
||||
type TabItemTitleProps = {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const TabItemTitle = ({
|
||||
icon,
|
||||
title,
|
||||
isActive,
|
||||
onClick,
|
||||
}: TabItemTitleProps) => {
|
||||
return (
|
||||
<Container active={isActive} onClick={onClick}>
|
||||
<IconWrapper>{icon}</IconWrapper>
|
||||
<StyledTitleText>{title}</StyledTitleText>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled(ListItem)({
|
||||
width: '110px',
|
||||
height: '32px',
|
||||
});
|
||||
|
||||
const IconWrapper = styled(ListIcon)({
|
||||
marginRight: '4px',
|
||||
});
|
||||
|
||||
const StyledTitleText = styled('span')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.menu,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { ContainerTabs } from './ContainerTabs';
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useRef, useEffect, useState, useMemo } from 'react';
|
||||
|
||||
function usePrevious<T>(value: T) {
|
||||
const ref = useRef<T>();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export function useTabs<K extends string>(tabs: K[], defaultTab?: K | null) {
|
||||
const state = useState<K | null>();
|
||||
const [selectedTab, setSelectedTab] = state;
|
||||
|
||||
const activeIndex = useMemo(() => {
|
||||
if (selectedTab) {
|
||||
return tabs.indexOf(selectedTab);
|
||||
}
|
||||
return -1;
|
||||
}, [selectedTab, tabs]);
|
||||
|
||||
const previousActiveIndex = usePrevious(activeIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0) {
|
||||
setSelectedTab(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedTab === null ||
|
||||
(selectedTab && tabs.includes(selectedTab))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof previousActiveIndex === 'number' &&
|
||||
previousActiveIndex >= 0 &&
|
||||
(tabs[previousActiveIndex] || tabs[previousActiveIndex - 1])
|
||||
) {
|
||||
setSelectedTab(
|
||||
tabs[previousActiveIndex] || tabs[previousActiveIndex - 1]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultTab === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTab(
|
||||
defaultTab && tabs.includes(defaultTab) ? defaultTab : tabs[0]
|
||||
);
|
||||
}, [defaultTab, previousActiveIndex, selectedTab, setSelectedTab, tabs]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export const LayoutSettings = () => {
|
||||
return (
|
||||
<StyledText>
|
||||
<p>Layout Settings Coming Soon...</p>
|
||||
</StyledText>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledText = styled('div')(({ theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
color: theme.affine.palette.menu,
|
||||
marginTop: theme.affine.spacing.lgSpacing,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { LayoutSettings } from './LayoutSettings';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { styled, ListItem, Divider, Switch } from '@toeverything/components/ui';
|
||||
import { useSettings } from './use-settings';
|
||||
|
||||
export const SettingsList = () => {
|
||||
const settings = useSettings();
|
||||
|
||||
return (
|
||||
<StyledSettingsList>
|
||||
{settings.map((item, index) => {
|
||||
const type = item.type;
|
||||
if (type === 'separator') {
|
||||
return <Divider key={index} />;
|
||||
}
|
||||
|
||||
if (type === 'switch') {
|
||||
return (
|
||||
<SwitchItemContainer
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
item.onChange(!item.value);
|
||||
}}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<Switch
|
||||
checked={item.value}
|
||||
checkedLabel="ON"
|
||||
uncheckedLabel="OFF"
|
||||
/>
|
||||
</SwitchItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem key={item.name} onClick={() => item.onClick()}>
|
||||
{item.name}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</StyledSettingsList>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledSettingsList = styled('div')({
|
||||
overflow: 'auto',
|
||||
padding: '0 4px',
|
||||
});
|
||||
|
||||
const SwitchItemContainer = styled(ListItem)({
|
||||
display: 'flex',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { SettingsList } from './SettingsList';
|
||||
import { Footer } from './footer';
|
||||
|
||||
export const SettingsPanel = () => {
|
||||
return (
|
||||
<StyledContainerForSettingsPanel>
|
||||
<SettingsList />
|
||||
<Footer />
|
||||
</StyledContainerForSettingsPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForSettingsPanel = styled('div')(({ theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 44,
|
||||
paddingBottom: 44,
|
||||
height: '100%',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FC } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { LastModified } from './LastModified';
|
||||
import { Logout } from './Logout';
|
||||
|
||||
export const Footer: FC = () => {
|
||||
return (
|
||||
<Container>
|
||||
<LastModified />
|
||||
<Logout />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from 'react';
|
||||
import format from 'date-fns/format';
|
||||
import { Typography, styled } from '@toeverything/components/ui';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { usePageLastUpdated, useWorkspaceAndPageId } from '../util';
|
||||
|
||||
export const LastModified: FC = () => {
|
||||
const { user } = useUserAndSpaces();
|
||||
const username = user ? user.nickname : 'Anonymous';
|
||||
const { workspaceId, pageId } = useWorkspaceAndPageId();
|
||||
const lastModified = usePageLastUpdated({ workspaceId, pageId });
|
||||
const formatLastModified = format(lastModified, 'MM/dd/yyyy hh:mm');
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<ContentText type="xs">
|
||||
<span>Last edited by </span>
|
||||
<span>{username}</span>
|
||||
</ContentText>
|
||||
</div>
|
||||
<div>
|
||||
<ContentText type="xs">{formatLastModified}</ContentText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ContentText = styled(Typography)(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.icons,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FC } from 'react';
|
||||
import { MoveToIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
ListItem,
|
||||
ListIcon,
|
||||
styled,
|
||||
Typography,
|
||||
} from '@toeverything/components/ui';
|
||||
import { LOGOUT_COOKIES, LOGOUT_LOCAL_STORAGE } from '@toeverything/utils';
|
||||
import { getAuth, signOut } from 'firebase/auth';
|
||||
|
||||
const logout = () => {
|
||||
LOGOUT_LOCAL_STORAGE.forEach(name => localStorage.removeItem(name));
|
||||
// localStorage.clear();
|
||||
document.cookie = LOGOUT_COOKIES.map(
|
||||
name => name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
|
||||
).join(' ');
|
||||
|
||||
signOut(getAuth());
|
||||
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
export const Logout: FC = () => {
|
||||
return (
|
||||
<ListItem onClick={logout}>
|
||||
<StyledIcon />
|
||||
<ContentText type="base">Logout</ContentText>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledIcon = styled(MoveToIcon)(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.icons,
|
||||
};
|
||||
});
|
||||
|
||||
const ContentText = styled(Typography)(({ theme }) => ({
|
||||
marginLeft: '12px',
|
||||
color: theme.affine.palette.menu,
|
||||
fontWeight: 300,
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export { Footer } from './Footer';
|
||||
@@ -0,0 +1 @@
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
|
||||
export const useSettingFlags = () => {
|
||||
const booleanFullWidthChecked = useFlag('BooleanFullWidthChecked', false);
|
||||
const booleanExportWorkspace = useFlag('BooleanExportWorkspace', false);
|
||||
const booleanImportWorkspace = useFlag('BooleanImportWorkspace', false);
|
||||
const booleanExportHtml = useFlag('BooleanExportHtml', false);
|
||||
const booleanExportPdf = useFlag('BooleanExportPdf', false);
|
||||
const booleanExportMarkdown = useFlag('BooleanExportMarkdown', false);
|
||||
|
||||
return {
|
||||
booleanFullWidthChecked,
|
||||
booleanExportWorkspace,
|
||||
booleanImportWorkspace,
|
||||
booleanExportHtml,
|
||||
booleanExportPdf,
|
||||
booleanExportMarkdown,
|
||||
};
|
||||
};
|
||||
|
||||
export type SettingFlags = ReturnType<typeof useSettingFlags>;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSettingFlags, type SettingFlags } from './use-setting-flags';
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import {
|
||||
duplicatePage,
|
||||
getPageTitle,
|
||||
exportHtml,
|
||||
exportMarkdown,
|
||||
importWorkspace,
|
||||
exportWorkspace,
|
||||
useWorkspaceAndPageId,
|
||||
useReadingMode,
|
||||
} from './util';
|
||||
|
||||
interface BaseSettingItem {
|
||||
flag?: keyof SettingFlags;
|
||||
}
|
||||
|
||||
interface SwitchItem extends BaseSettingItem {
|
||||
name: string;
|
||||
type: 'switch';
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface SeparatorItem extends BaseSettingItem {
|
||||
type: 'separator';
|
||||
}
|
||||
|
||||
interface ButtonItem extends BaseSettingItem {
|
||||
name: string;
|
||||
type: 'button';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
type SettingItem = SwitchItem | SeparatorItem | ButtonItem;
|
||||
|
||||
const filterSettings = (settings: SettingItem[], flags: SettingFlags) => {
|
||||
return settings
|
||||
.filter(setting => {
|
||||
if (!setting.flag) {
|
||||
return true;
|
||||
}
|
||||
return flags[setting.flag];
|
||||
})
|
||||
.filter((setting, index, array) => {
|
||||
if (setting.type === 'separator') {
|
||||
if (
|
||||
// If the current is a separator, and the following is also a separator, delete the current one
|
||||
array[index + 1]?.type === 'separator' ||
|
||||
// If the current separator is the last one, delete the current one
|
||||
index === array.length - 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const useSettings = (): SettingItem[] => {
|
||||
const { workspaceId, pageId } = useWorkspaceAndPageId();
|
||||
const navigate = useNavigate();
|
||||
const settingFlags = useSettingFlags();
|
||||
const { toggleReadingMode, readingMode } = useReadingMode();
|
||||
|
||||
const settings: SettingItem[] = [
|
||||
{
|
||||
type: 'switch',
|
||||
name: 'Reading Mode',
|
||||
value: readingMode,
|
||||
onChange: () => {
|
||||
toggleReadingMode();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Duplicate Page',
|
||||
onClick: async () => {
|
||||
const newPageInfo = await duplicatePage({
|
||||
workspaceId,
|
||||
pageId,
|
||||
});
|
||||
navigate(`/${newPageInfo.workspaceId}/${newPageInfo.pageId}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Copy Page Link',
|
||||
onClick: () => copyToClipboard(window.location.href),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Export As Markdown',
|
||||
onClick: async () => {
|
||||
const title = await getPageTitle({ workspaceId, pageId });
|
||||
exportMarkdown({ workspaceId, rootBlockId: pageId, title });
|
||||
},
|
||||
flag: 'booleanExportMarkdown',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Export As HTML',
|
||||
onClick: async () => {
|
||||
const title = await getPageTitle({ workspaceId, pageId });
|
||||
exportHtml({ workspaceId, rootBlockId: pageId, title });
|
||||
},
|
||||
flag: 'booleanExportHtml',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Export As PDF (Unsupported)',
|
||||
onClick: () => console.log('Export As PDF'),
|
||||
flag: 'booleanExportPdf',
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Import Workspace',
|
||||
onClick: () => importWorkspace(workspaceId),
|
||||
flag: 'booleanImportWorkspace',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
name: 'Export Workspace',
|
||||
onClick: () => exportWorkspace(),
|
||||
flag: 'booleanExportWorkspace',
|
||||
},
|
||||
];
|
||||
|
||||
return filterSettings(settings, settingFlags);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
|
||||
interface DuplicatePageProps {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
}
|
||||
export const duplicatePage = async ({
|
||||
workspaceId,
|
||||
pageId,
|
||||
}: DuplicatePageProps) => {
|
||||
//create page
|
||||
const newPage = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
//add page to tree
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
//copy source page to new page
|
||||
await services.api.editorBlock.copyPage(workspaceId, pageId, newPage.id);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
pageId: newPage.id,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import TurndownService from 'turndown';
|
||||
|
||||
export const fileExporter = {
|
||||
exportFile: (filename: string, text: string, format: string) => {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute(
|
||||
'href',
|
||||
'data:' + format + ';charset=utf-8,' + encodeURIComponent(text)
|
||||
);
|
||||
element.setAttribute('download', filename);
|
||||
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
},
|
||||
decorateHtml: (pageTitle: string, htmlContent: string) => {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${pageTitle}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="margin:20px auto;width:800px" >
|
||||
<div style="background-color: #fff;box-shadow: 0px 0px 5px #ccc;padding: 10px;">
|
||||
${htmlContent}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
},
|
||||
decoreateAffineBrand: (pageTitle: string) => {
|
||||
return pageTitle + ` Created in Affine`;
|
||||
},
|
||||
exportHtml: (pageTitle: string, htmlContent: string) => {
|
||||
fileExporter.exportFile(
|
||||
fileExporter.decoreateAffineBrand(pageTitle) + '.html',
|
||||
fileExporter.decorateHtml(pageTitle, htmlContent),
|
||||
'text/html'
|
||||
);
|
||||
},
|
||||
|
||||
exportMarkdown: (pageTitle: string, htmlContent: string) => {
|
||||
const turndownService = new TurndownService();
|
||||
const markdown = turndownService.turndown(htmlContent);
|
||||
|
||||
fileExporter.exportFile(
|
||||
fileExporter.decoreateAffineBrand(pageTitle) + '.md',
|
||||
markdown,
|
||||
'text/plain'
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
|
||||
const UNTITLED = 'untitled';
|
||||
|
||||
interface GetPageTitleProps {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export const getPageTitle = async ({
|
||||
workspaceId,
|
||||
pageId,
|
||||
}: GetPageTitleProps) => {
|
||||
return await services.api.editorBlock
|
||||
.get({ workspace: workspaceId, ids: [pageId] })
|
||||
.then(blockData => {
|
||||
if (!blockData?.[0]) {
|
||||
return UNTITLED;
|
||||
}
|
||||
return blockData[0].properties.text.value.map(v => v.text).join('');
|
||||
});
|
||||
};
|
||||
|
||||
const getPageLastUpdated = async ({
|
||||
workspaceId,
|
||||
pageId,
|
||||
}: GetPageTitleProps) => {
|
||||
return await services.api.editorBlock
|
||||
.getBlock(workspaceId, pageId)
|
||||
.then(block => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
return block.lastUpdated;
|
||||
});
|
||||
};
|
||||
|
||||
export const usePageLastUpdated = ({
|
||||
workspaceId,
|
||||
pageId,
|
||||
}: GetPageTitleProps) => {
|
||||
const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
|
||||
useEffect(() => {
|
||||
getPageLastUpdated({ workspaceId, pageId }).then(d => {
|
||||
setLastUpdated(d ? d : Date.now());
|
||||
});
|
||||
}, [workspaceId, pageId]);
|
||||
return lastUpdated;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ClipboardParse } from '@toeverything/components/editor-core';
|
||||
import { createEditor } from '@toeverything/components/affine-editor';
|
||||
import { fileExporter } from './file-exporter';
|
||||
|
||||
interface CreateClipboardParseProps {
|
||||
workspaceId: string;
|
||||
rootBlockId: string;
|
||||
}
|
||||
|
||||
const createClipboardParse = ({
|
||||
workspaceId,
|
||||
rootBlockId,
|
||||
}: CreateClipboardParseProps) => {
|
||||
const editor = createEditor(workspaceId);
|
||||
editor.setRootBlockId(rootBlockId);
|
||||
|
||||
return new ClipboardParse(editor);
|
||||
};
|
||||
|
||||
interface ExportHandlerProps extends CreateClipboardParseProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const exportHtml = async ({
|
||||
workspaceId,
|
||||
rootBlockId,
|
||||
title,
|
||||
}: ExportHandlerProps) => {
|
||||
const clipboardParse = createClipboardParse({ workspaceId, rootBlockId });
|
||||
const htmlContent = await clipboardParse.page2html();
|
||||
fileExporter.exportHtml(title, htmlContent);
|
||||
};
|
||||
|
||||
export const exportMarkdown = async ({
|
||||
workspaceId,
|
||||
rootBlockId,
|
||||
title,
|
||||
}: ExportHandlerProps) => {
|
||||
const clipboardParse = createClipboardParse({ workspaceId, rootBlockId });
|
||||
const htmlContent = await clipboardParse.page2html();
|
||||
fileExporter.exportMarkdown(title, htmlContent);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { duplicatePage } from './duplicate-page';
|
||||
export { exportHtml, exportMarkdown } from './handle-export';
|
||||
export { getPageTitle, usePageLastUpdated } from './get-page-info';
|
||||
export { importWorkspace, exportWorkspace } from './inspector-workspace';
|
||||
export { useWorkspaceAndPageId } from './use-workspace-page';
|
||||
export { useReadingMode } from './use-reading-mode';
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @deprecated debugging method, deprecated
|
||||
*/
|
||||
export const importWorkspace = (workspaceId: string) => {
|
||||
//@ts-ignore
|
||||
window.client
|
||||
.inspector()
|
||||
.load()
|
||||
.then(() => {
|
||||
window.location.href = `/${workspaceId}/`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated debugging method, deprecated
|
||||
*/
|
||||
export const exportWorkspace = () => {
|
||||
//@ts-ignore
|
||||
window.client.inspector().save();
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated debugging method, deprecated
|
||||
*/
|
||||
export const clearWorkspace = async workspaceId => {
|
||||
//@ts-ignore
|
||||
if (window.confirm('Are you sure you want to clear the workspace?')) {
|
||||
//@ts-ignore
|
||||
await window.client.inspector().clear();
|
||||
window.location.href = `/${workspaceId}/`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
useShowSettingsSidebar,
|
||||
useShowSpaceSidebar,
|
||||
} from '@toeverything/datasource/state';
|
||||
|
||||
/**
|
||||
* TODO: This is not Reading Mode, it needs to be discussed later, so I will write it now
|
||||
*/
|
||||
export const useReadingMode = () => {
|
||||
const { setShowSettingsSidebar } = useShowSettingsSidebar();
|
||||
const { fixedDisplay, toggleSpaceSidebar } = useShowSpaceSidebar();
|
||||
const readingMode = !fixedDisplay;
|
||||
|
||||
const toggleReadingMode = () => {
|
||||
toggleSpaceSidebar();
|
||||
setShowSettingsSidebar(readingMode);
|
||||
};
|
||||
|
||||
return {
|
||||
readingMode,
|
||||
toggleReadingMode,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
interface UseWorkspaceAndPageIdReturn {
|
||||
workspaceId?: string;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
export const useWorkspaceAndPageId = (): UseWorkspaceAndPageIdReturn => {
|
||||
const params = useParams();
|
||||
const workspaceId = params['workspace_id'];
|
||||
const pageId = params['*'].split('/')[0];
|
||||
return {
|
||||
workspaceId,
|
||||
pageId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useShowSettingsSidebar } from '@toeverything/datasource/state';
|
||||
import { ContainerTabs } from './ContainerTabs';
|
||||
|
||||
const SETTINGS_SIDEBAR_WIDTH = 370;
|
||||
|
||||
export const SettingsSidebar = () => {
|
||||
const { showSettingsSidebar } = useShowSettingsSidebar();
|
||||
|
||||
return (
|
||||
<StyledContainerForSidebar
|
||||
isShow={showSettingsSidebar}
|
||||
id="id-side-panel"
|
||||
>
|
||||
{showSettingsSidebar ? <ContainerTabs /> : null}
|
||||
</StyledContainerForSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainerForSidebar = styled('div', {
|
||||
shouldForwardProp: (prop: string) => !['isShow'].includes(prop),
|
||||
})<{ isShow?: boolean }>(({ theme, isShow }) => {
|
||||
return {
|
||||
flex: 'none',
|
||||
width: isShow ? SETTINGS_SIDEBAR_WIDTH : 0,
|
||||
// TODO: animation not working
|
||||
transition: 'all 300ms ease-in-out',
|
||||
borderLeft: `1px solid ${theme.affine.palette.menuSeparator}`,
|
||||
zIndex: 100,
|
||||
backgroundColor: 'white',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
});
|
||||
1
libs/components/layout/src/settings-sidebar/index.ts
Normal file
1
libs/components/layout/src/settings-sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsSidebar } from './SettingsSidebar';
|
||||
@@ -0,0 +1,5 @@
|
||||
export const useSettingsSidebar = () => {
|
||||
return {
|
||||
show: false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
MuiList as List,
|
||||
MuiListItem as ListItem,
|
||||
MuiListItemText as ListItemText,
|
||||
MuiListItemButton as ListItemButton,
|
||||
} from '@toeverything/components/ui';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const StyledWrapper = styled('div')({
|
||||
margin: '0 16px 0 32px',
|
||||
span: {
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.item': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
ustifyContent: 'space-between',
|
||||
padding: '7px 0px',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
'.itemButton': {
|
||||
padding: 0,
|
||||
height: 32,
|
||||
},
|
||||
'.itemLeft': {
|
||||
color: '#4c6275',
|
||||
marginRight: '20px',
|
||||
span: {
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
'.itemRight': {
|
||||
color: '#B6C7D3',
|
||||
flex: 'none',
|
||||
span: {
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Activities = () => {
|
||||
const { user, currentSpaceId } = useUserAndSpaces();
|
||||
const [recenPages, setRecentPages] = useState([]);
|
||||
|
||||
const fetchRecentPages = useCallback(async () => {
|
||||
if (!user || !currentSpaceId) {
|
||||
return;
|
||||
}
|
||||
const recent_pages = await services.api.userConfig.getRecentPages(
|
||||
currentSpaceId,
|
||||
user.id
|
||||
);
|
||||
setRecentPages(recent_pages);
|
||||
}, [user, currentSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentPages();
|
||||
}, [user, currentSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let unobserve: () => void;
|
||||
const observe = async () => {
|
||||
unobserve = await services.api.userConfig.observe(
|
||||
{ workspace: currentSpaceId },
|
||||
() => {
|
||||
fetchRecentPages();
|
||||
}
|
||||
);
|
||||
};
|
||||
observe();
|
||||
|
||||
return () => {
|
||||
unobserve?.();
|
||||
};
|
||||
}, [currentSpaceId, fetchRecentPages]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<List>
|
||||
{recenPages.map(({ id, title, lastOpenTime }) => {
|
||||
return (
|
||||
<ListItem className="item" key={id}>
|
||||
<ListItemButton
|
||||
className="itemButton"
|
||||
onClick={() => {
|
||||
navigate(`/${currentSpaceId}/${id}`);
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
className="itemLeft"
|
||||
primary={title}
|
||||
/>
|
||||
<ListItemText
|
||||
className="itemRight"
|
||||
primary={formatDistanceToNow(lastOpenTime, {
|
||||
includeSeconds: true,
|
||||
})}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './activities';
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import {
|
||||
styled,
|
||||
MuiBox as Box,
|
||||
MuiTextField as TextField,
|
||||
// CalendarPickerSkeleton,
|
||||
LocalizationProvider,
|
||||
AdapterDateFns,
|
||||
StaticDatePicker,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
import type { Theme } from './types';
|
||||
import { useCalendarHeatmap } from './use-calendar-heatmap';
|
||||
|
||||
export type CalendarHeatmapProps = {
|
||||
calendarTheme?: Theme;
|
||||
};
|
||||
|
||||
/** Calendar heat map component, different colors represent the number of operation records on the day */
|
||||
export function CalendarHeatmap({ calendarTheme }: CalendarHeatmapProps) {
|
||||
const {
|
||||
fetchHighlightedDays,
|
||||
pickDay,
|
||||
setPickDay,
|
||||
handleMonthChange,
|
||||
renderDayWithHeatmap,
|
||||
} = useCalendarHeatmap({ calendarTheme });
|
||||
|
||||
useEffect(() => {
|
||||
fetchHighlightedDays();
|
||||
}, [fetchHighlightedDays]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
id="affineCalendarHeatmapContainer"
|
||||
sx={{
|
||||
// flex: 1,
|
||||
'.MuiPickerStaticWrapper-root': {
|
||||
minWidth: 240,
|
||||
},
|
||||
'.MuiCalendarPicker-root': {
|
||||
width: 280,
|
||||
margin: 0,
|
||||
marginLeft: '-8px',
|
||||
'& > div': {
|
||||
marginTop: 0,
|
||||
},
|
||||
'& .PrivatePickersFadeTransitionGroup-root > div': {
|
||||
color: '#98acbd',
|
||||
fontSize: '0.8rem',
|
||||
},
|
||||
"& > div > div[role='presentation']": {
|
||||
marginLeft: '8px',
|
||||
},
|
||||
"& div[role='presentation'] > button:first-of-type": {
|
||||
display: 'none',
|
||||
},
|
||||
'& .PrivatePickersSlideTransition-root': {
|
||||
minHeight: 290,
|
||||
},
|
||||
'& .MuiIconButton-sizeSmall svg': {
|
||||
color: '#98acbd',
|
||||
},
|
||||
"& div[class][style*='cubic-bezier']": {
|
||||
marginRight: '8px',
|
||||
},
|
||||
"& div[class][style*='cubic-bezier'] > div": {
|
||||
width: '8px',
|
||||
},
|
||||
'& .PrivatePickersFadeTransitionGroup-root.MuiCalendarPicker-viewTransitionContainer':
|
||||
{
|
||||
height: 260,
|
||||
},
|
||||
'& .MuiCalendarPicker-viewTransitionContainer .MuiTypography-caption':
|
||||
{
|
||||
width: 30,
|
||||
height: 30,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<StaticDatePicker
|
||||
value={pickDay}
|
||||
onChange={setPickDay}
|
||||
displayStaticWrapperAs="desktop"
|
||||
onMonthChange={handleMonthChange}
|
||||
label="Week picker"
|
||||
inputFormat="'Week of' MMM d"
|
||||
renderInput={params => <TextField {...(params as any)} />}
|
||||
renderDay={renderDayWithHeatmap}
|
||||
disableHighlightToday={true}
|
||||
autoFocus={false}
|
||||
// loading={loading}
|
||||
// renderLoading={() => <CalendarPickerSkeleton />}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
styled,
|
||||
PickersDay,
|
||||
type PickersDayProps,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
import type { Theme } from './types';
|
||||
import { DEFAULT_THEME } from './utils';
|
||||
|
||||
type HeatedDayProps = PickersDayProps<Date> & {
|
||||
activitiesOfDay: number;
|
||||
calendarTheme: Theme;
|
||||
};
|
||||
|
||||
export const HeatedDay = styled(PickersDay, {
|
||||
shouldForwardProp: (prop: string) =>
|
||||
!['activitiesOfDay', 'calendarTheme'].includes(prop),
|
||||
})<HeatedDayProps>(({ calendarTheme = DEFAULT_THEME, activitiesOfDay }) => ({
|
||||
...{
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: '0 2.5px 16px 2.5px',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#fff',
|
||||
color: '#4E687C',
|
||||
// fontSize: '0.8rem'
|
||||
},
|
||||
...(activitiesOfDay > 0 && {
|
||||
backgroundColor: calendarTheme.level1,
|
||||
'&:hover': {
|
||||
backgroundColor: calendarTheme.level1,
|
||||
},
|
||||
}),
|
||||
...(activitiesOfDay > 3 && {
|
||||
backgroundColor: calendarTheme.level2,
|
||||
'&:hover': {
|
||||
backgroundColor: calendarTheme.level2,
|
||||
},
|
||||
}),
|
||||
...(activitiesOfDay > 6 && {
|
||||
backgroundColor: calendarTheme.level3,
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: calendarTheme.level3,
|
||||
},
|
||||
}),
|
||||
})) as unknown as ComponentType<HeatedDayProps>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CalendarHeatmap } from './CalendarHeatmap';
|
||||
export { useCalendarHeatmap } from './use-calendar-heatmap';
|
||||
@@ -0,0 +1,17 @@
|
||||
export type CalendarDay = {
|
||||
level?: Level;
|
||||
/** Date number, 1-31 */
|
||||
dayInMonth: number;
|
||||
/** The number of current operation records, currently using the number of pages currently created */
|
||||
activitiesOfDay: number;
|
||||
};
|
||||
|
||||
export type Level = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export type Theme = {
|
||||
readonly level1: string;
|
||||
readonly level2: string;
|
||||
readonly level3: string;
|
||||
readonly level4?: string;
|
||||
readonly level5?: string;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState, createElement } from 'react';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import type { PickersDayProps } from '@toeverything/components/ui';
|
||||
|
||||
import type { CalendarDay } from './types';
|
||||
import type { CalendarHeatmapProps } from './CalendarHeatmap';
|
||||
import { HeatedDay } from './HeatedDay';
|
||||
import { fakeFetch, fetchActivitiesHeatmap } from './utils';
|
||||
// import { usePageTree } from 'PageTree';
|
||||
|
||||
const highlightedDaysAtom = atom<CalendarDay[] | undefined>([]);
|
||||
|
||||
export const useCalendarHeatmap = ({
|
||||
calendarTheme,
|
||||
}: CalendarHeatmapProps = {}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedDays, setHighlightedDays] = useAtom(highlightedDaysAtom);
|
||||
const [pickDay, setPickDay] = useState<Date | null>(null);
|
||||
|
||||
// const { flattenedItems } = usePageTree();
|
||||
|
||||
const fetchHighlightedDays = useCallback(
|
||||
async (date?: Date) => {
|
||||
// const { daysToHighlight } = await fetchActivitiesHeatmap(db, date || new Date(), flattenedItems);
|
||||
// setHighlightedDays(daysToHighlight);
|
||||
setHighlightedDays([]);
|
||||
// setLoading(false);
|
||||
},
|
||||
[setHighlightedDays]
|
||||
);
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(date: Date) => {
|
||||
// setLoading(true);
|
||||
// setHighlightedDays([]);
|
||||
fetchHighlightedDays(date);
|
||||
},
|
||||
[fetchHighlightedDays]
|
||||
);
|
||||
|
||||
const addPageToday = useCallback(() => {
|
||||
const foundToday = highlightedDays?.find(
|
||||
hDay => hDay.dayInMonth === new Date().getDate()
|
||||
);
|
||||
if (foundToday) {
|
||||
setHighlightedDays([
|
||||
...highlightedDays.filter(
|
||||
day => day.dayInMonth !== foundToday.dayInMonth
|
||||
),
|
||||
{
|
||||
...foundToday,
|
||||
activitiesOfDay: foundToday.activitiesOfDay + 1,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setHighlightedDays([
|
||||
...highlightedDays,
|
||||
{ dayInMonth: new Date().getDate(), activitiesOfDay: 1 },
|
||||
]);
|
||||
}
|
||||
}, [highlightedDays, setHighlightedDays]);
|
||||
|
||||
const renderDayWithHeatmap = useCallback(
|
||||
(
|
||||
day: Date,
|
||||
selectedDates: Array<Date | null>,
|
||||
pickersDayProps: PickersDayProps<Date>
|
||||
) => {
|
||||
const foundDay = highlightedDays?.find(
|
||||
hDay => hDay.dayInMonth === day.getDate()
|
||||
);
|
||||
const isSelected = !pickersDayProps.outsideCurrentMonth && foundDay;
|
||||
|
||||
return createElement(HeatedDay, {
|
||||
...pickersDayProps,
|
||||
calendarTheme,
|
||||
activitiesOfDay: isSelected ? foundDay.activitiesOfDay : 0,
|
||||
});
|
||||
// return (
|
||||
// <HeatedDay
|
||||
// {...pickersDayProps}
|
||||
// calendarTheme={calendarTheme}
|
||||
// activitiesOfDay={isSelected ? foundDay.activitiesOfDay : 0}
|
||||
// />
|
||||
// );
|
||||
},
|
||||
[highlightedDays, calendarTheme]
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
fetchHighlightedDays,
|
||||
pickDay,
|
||||
setPickDay,
|
||||
handleMonthChange,
|
||||
renderDayWithHeatmap,
|
||||
addPageToday,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
BlockClientInstance,
|
||||
BlockImplInstance,
|
||||
} from '@toeverything/datasource/jwt';
|
||||
import { getDateIsoStringWithTimezone } from '@toeverything/utils';
|
||||
import getDaysInMonth from 'date-fns/getDaysInMonth';
|
||||
import color, { ColorInput } from 'tinycolor2';
|
||||
|
||||
import type { Theme, CalendarDay } from './types';
|
||||
|
||||
// export const DEFAULT_THEME = createCalendarTheme('#3E6FDB');
|
||||
export const DEFAULT_THEME: Theme = {
|
||||
level4: '#3E6FDB',
|
||||
level3: 'rgba(62, 111, 219)',
|
||||
level2: 'rgba(62, 111, 219, 0.5)',
|
||||
level1: 'rgba(62, 111, 219, 0.2)',
|
||||
};
|
||||
|
||||
export function createCalendarTheme(
|
||||
baseColor: ColorInput,
|
||||
emptyColor = color('white').darken(8).toHslString()
|
||||
): Theme {
|
||||
const base = color(baseColor);
|
||||
|
||||
if (!base.isValid()) {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return {
|
||||
level4: base.setAlpha(0.92).toHslString(),
|
||||
level3: base.setAlpha(0.76).toHslString(),
|
||||
level2: base.setAlpha(0.6).toHslString(),
|
||||
level1: base.setAlpha(0.44).toHslString(),
|
||||
// level0: emptyColor
|
||||
};
|
||||
}
|
||||
|
||||
function getRandomNumber(min: number, max: number) {
|
||||
return Math.round(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
export async function fetchActivitiesHeatmap(
|
||||
db: BlockClientInstance,
|
||||
date: Date,
|
||||
flattenedItems: any[]
|
||||
): Promise<{ daysToHighlight: CalendarDay[] }> {
|
||||
// const pages = await db.getByType('page');
|
||||
const pages_with_ids = (await Promise.all(
|
||||
flattenedItems.map(async (page_item: any) => {
|
||||
const page_id = page_item.id;
|
||||
return [page_id, await db.get(page_id as 'page')];
|
||||
})
|
||||
)) as [string, BlockImplInstance][];
|
||||
const pages = new Map(pages_with_ids);
|
||||
|
||||
const blocks = pages.values();
|
||||
const pages_by_month = {} as Record<string, BlockImplInstance[]>;
|
||||
|
||||
for (const page of blocks) {
|
||||
const page_created_month = getDateIsoStringWithTimezone(
|
||||
page.created
|
||||
).slice(0, 7);
|
||||
if (!pages_by_month[page_created_month]) {
|
||||
pages_by_month[page_created_month] = [page];
|
||||
} else {
|
||||
pages_by_month[page_created_month].push(page);
|
||||
}
|
||||
}
|
||||
|
||||
const result_date_key = getDateIsoStringWithTimezone(date.getTime()).slice(
|
||||
0,
|
||||
7
|
||||
);
|
||||
if (!pages_by_month[result_date_key]) {
|
||||
return { daysToHighlight: [] };
|
||||
}
|
||||
|
||||
const pages_by_day = {} as Record<string, BlockImplInstance[]>;
|
||||
const daysToHighlight = [] as CalendarDay[];
|
||||
pages_by_month[result_date_key].forEach(page => {
|
||||
const page_created_date = new Date(page.created).getDate();
|
||||
if (!pages_by_day[page_created_date]) {
|
||||
pages_by_day[page_created_date] = [page];
|
||||
} else {
|
||||
pages_by_day[page_created_date].push(page);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(pages_by_day).forEach(day => {
|
||||
daysToHighlight.push({
|
||||
dayInMonth: Number(day),
|
||||
activitiesOfDay: pages_by_day[day].length,
|
||||
});
|
||||
});
|
||||
|
||||
return { daysToHighlight };
|
||||
}
|
||||
|
||||
export async function fakeFetch(
|
||||
date: Date,
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
): Promise<{ daysToHighlight: CalendarDay[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// const timeout = setTimeout(() => {
|
||||
const daysInMonth = getDaysInMonth(date);
|
||||
const daysToHighlight = Array(7)
|
||||
.fill(1)
|
||||
.map(() => {
|
||||
const countOfDay = getRandomNumber(1, daysInMonth);
|
||||
return {
|
||||
dayInMonth: countOfDay,
|
||||
activitiesOfDay: countOfDay,
|
||||
};
|
||||
});
|
||||
resolve({ daysToHighlight });
|
||||
// }, 0);
|
||||
|
||||
if (signal) {
|
||||
signal.onabort = () => {
|
||||
// clearTimeout(timeout);
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
3
libs/components/layout/src/workspace-sidebar/index.ts
Normal file
3
libs/components/layout/src/workspace-sidebar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './page-tree';
|
||||
export * from './calendar-heatmap';
|
||||
export * from './activities';
|
||||
142
libs/components/layout/src/workspace-sidebar/page-tree/DndTree.tsx
Executable file
142
libs/components/layout/src/workspace-sidebar/page-tree/DndTree.tsx
Executable file
@@ -0,0 +1,142 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
DropAnimation,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
defaultDropAnimation,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import { DndTreeItem } from './tree-item';
|
||||
import type { TreeItems } from './types';
|
||||
import { usePageTree } from './use-page-tree';
|
||||
import { getChildCount } from './utils';
|
||||
|
||||
const measuring = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
};
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
...defaultDropAnimation,
|
||||
// dragSourceOpacity: 0.5,
|
||||
};
|
||||
|
||||
export type DndTreeProps = {
|
||||
defaultItems?: TreeItems;
|
||||
indentationWidth?: number;
|
||||
collapsible?: boolean;
|
||||
removable?: boolean;
|
||||
showDragIndicator?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Currently does not support drag and drop using the keyboard.
|
||||
*/
|
||||
export function DndTree(props: DndTreeProps) {
|
||||
const {
|
||||
indentationWidth = 16,
|
||||
collapsible,
|
||||
removable,
|
||||
showDragIndicator,
|
||||
} = props;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
const {
|
||||
items,
|
||||
activeId,
|
||||
flattenedItems,
|
||||
projected,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
handleRemove,
|
||||
handleCollapse,
|
||||
} = usePageTree(props);
|
||||
|
||||
const sortedIds = useMemo(
|
||||
() => flattenedItems.map(({ id }) => id),
|
||||
[flattenedItems]
|
||||
);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() =>
|
||||
activeId ? flattenedItems.find(({ id }) => id === activeId) : null,
|
||||
[activeId, flattenedItems]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
measuring={measuring}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{/* <button onClick={() => handleAdd()}> add top node</button> */}
|
||||
{flattenedItems.map(
|
||||
({ id, title, children, collapsed, depth }) => (
|
||||
<DndTreeItem
|
||||
key={id}
|
||||
id={id}
|
||||
// value={id}
|
||||
value={title}
|
||||
collapsed={Boolean(collapsed && children.length)}
|
||||
depth={
|
||||
id === activeId && projected
|
||||
? projected.depth
|
||||
: depth
|
||||
}
|
||||
indentationWidth={indentationWidth}
|
||||
indicator={showDragIndicator}
|
||||
onCollapse={
|
||||
collapsible && children.length
|
||||
? () => handleCollapse(id)
|
||||
: undefined
|
||||
}
|
||||
onRemove={
|
||||
removable ? () => handleRemove(id) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimation}
|
||||
style={{ marginTop: '-65px' }}
|
||||
>
|
||||
{activeId && activeItem ? (
|
||||
<DndTreeItem
|
||||
id={activeId}
|
||||
// value={activeId}
|
||||
value={activeItem.title}
|
||||
depth={activeItem.depth}
|
||||
clone={true}
|
||||
childCount={getChildCount(items, activeId) + 1}
|
||||
indentationWidth={indentationWidth}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// import type {
|
||||
// ArrayOperation,
|
||||
// BlockClientInstance,
|
||||
// BlockImplInstance,
|
||||
// MapOperation,
|
||||
// } from '@toeverything/datasource/jwt';
|
||||
// import { getDateIsoStringWithTimezone } from '@toeverything/utils';
|
||||
|
||||
// export async function getRecentPages(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string
|
||||
// ): Promise<string[]> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig<MapOperation<string>>();
|
||||
// const recent_pages = config.get('recent_pages');
|
||||
// if (recent_pages?.get(userId)) {
|
||||
// return [recent_pages.get(userId) as string];
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
|
||||
// return [''];
|
||||
// }
|
||||
|
||||
// /** Mark the article corresponding to pageId as the most recently accessed article */
|
||||
// export async function setRecentPages(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string,
|
||||
// pageId: string
|
||||
// ): Promise<void> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig<MapOperation<string>>();
|
||||
// const recent_pages = config.get('recent_pages');
|
||||
// if (recent_pages && recent_pages.get(userId)) {
|
||||
// recent_pages.set(userId, pageId);
|
||||
// } else {
|
||||
// const map = config.createMap<string>();
|
||||
// map.set(userId, pageId);
|
||||
// config.set('recent_pages', map);
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// export async function setPageTree<TreeItem>(
|
||||
// db: BlockClientInstance,
|
||||
// treeData: TreeItem[]
|
||||
// ): Promise<void> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig();
|
||||
// const array = config.createArray<TreeItem>();
|
||||
// array.push((treeData as any[]) || []);
|
||||
// config.set('page_tree', array);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// async function update_tree_items_title<
|
||||
// TreeItem extends { id: string; title: string; children: TreeItem[] }
|
||||
// >(
|
||||
// db: BlockClientInstance,
|
||||
// items: TreeItem[],
|
||||
// cache: Record<string, string>
|
||||
// ): Promise<TreeItem[]> {
|
||||
// for (const item of items) {
|
||||
// if (cache[item.id]) {
|
||||
// item.title = cache[item.id];
|
||||
// } else {
|
||||
// const page = await db.get(item.id as 'page');
|
||||
// item.title =
|
||||
// page.getDecoration<Array<{ text: string }>>('text')?.[0]
|
||||
// ?.text ||
|
||||
// 'Untitled ' +
|
||||
// getDateIsoStringWithTimezone(page.created)
|
||||
// .slice(11)
|
||||
// .replace('T', ' ');
|
||||
// cache[item.id] = item.title;
|
||||
// }
|
||||
|
||||
// if (item.children.length) {
|
||||
// item.children = await update_tree_items_title(
|
||||
// db,
|
||||
// item.children,
|
||||
// cache
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// return [...items];
|
||||
// }
|
||||
|
||||
// export async function getPageTree<TreeItem>(
|
||||
// db: BlockClientInstance
|
||||
// ): Promise<TreeItem[]> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig();
|
||||
// const page_tree = config.get('page_tree') as ArrayOperation<TreeItem>;
|
||||
// const page_tree_items = page_tree?.slice();
|
||||
// if (page_tree && page_tree_items?.length) {
|
||||
// const pages = await update_tree_items_title(
|
||||
// db,
|
||||
// page_tree_items as [],
|
||||
// {}
|
||||
// );
|
||||
// return pages;
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// export async function getWorkspaceConfig(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string,
|
||||
// name: string
|
||||
// ): Promise<string> {
|
||||
// try {
|
||||
// // const config = await db.getWorkspaceConfig();
|
||||
// // const map = config.get_map<string>(name);
|
||||
// // if (map && map.get(userId)) {
|
||||
// // return map.get(userId)!;
|
||||
// // }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
|
||||
// return '';
|
||||
// }
|
||||
|
||||
// export async function setWorkspaceConfig(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string,
|
||||
// name: string,
|
||||
// value: string
|
||||
// ): Promise<void> {
|
||||
// try {
|
||||
// // const config = await db.getWorkspaceConfig();
|
||||
// // const map = config.get_map<string>(name);
|
||||
// // map.set(userId, value === undefined || value === null ? '' : value);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// export async function createPage(
|
||||
// db: BlockClientInstance,
|
||||
// title?: string,
|
||||
// content?: string,
|
||||
// metadata?: Record<string, string>
|
||||
// ): Promise<BlockImplInstance | undefined> {
|
||||
// if (db) {
|
||||
// const page: BlockImplInstance = await db.get('page');
|
||||
// if (title) {
|
||||
// page.getContent<string>().set('title', title);
|
||||
// }
|
||||
// if (metadata) {
|
||||
// Object.keys(metadata).forEach(property =>
|
||||
// page.getContent<string>().set(property, metadata[property])
|
||||
// );
|
||||
// }
|
||||
// return page;
|
||||
// }
|
||||
|
||||
// return undefined;
|
||||
// }
|
||||
@@ -0,0 +1,6 @@
|
||||
export { PageTree } from './page-tree';
|
||||
export { usePageTree } from './use-page-tree';
|
||||
|
||||
export * from './types';
|
||||
|
||||
// export { getRecentPages, setRecentPages } from './block-page';
|
||||
22
libs/components/layout/src/workspace-sidebar/page-tree/page-tree.tsx
Executable file
22
libs/components/layout/src/workspace-sidebar/page-tree/page-tree.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import style9 from 'style9';
|
||||
|
||||
import { DndTree } from './DndTree';
|
||||
import { useDndTreeAutoUpdate } from './use-page-tree';
|
||||
|
||||
const styles = style9.create({
|
||||
root: {
|
||||
minWidth: 160,
|
||||
maxWidth: 260,
|
||||
marginLeft: 18,
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export const PageTree = () => {
|
||||
useDndTreeAutoUpdate();
|
||||
return (
|
||||
<div className={styles('root')}>
|
||||
<DndTree collapsible removable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { iOS } from '../utils';
|
||||
import { TreeItem, TreeItemProps } from './TreeItem';
|
||||
|
||||
type DndTreeItemProps = TreeItemProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function DndTreeItem({ id, depth, ...props }: DndTreeItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
isSorting,
|
||||
listeners,
|
||||
setDraggableNodeRef,
|
||||
setDroppableNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
ref={setDraggableNodeRef}
|
||||
wrapperRef={setDroppableNodeRef}
|
||||
pageId={id}
|
||||
style={style}
|
||||
depth={depth}
|
||||
ghost={isDragging}
|
||||
disableSelection={iOS}
|
||||
disableInteraction={isSorting}
|
||||
handleProps={{
|
||||
...attributes,
|
||||
...listeners,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import styles from './tree-item.module.scss';
|
||||
import {
|
||||
MuiSnackbar as Snackbar,
|
||||
Cascader,
|
||||
CascaderItemProps,
|
||||
MuiDivider as Divider,
|
||||
} from '@toeverything/components/ui';
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import { services, TemplateFactory } from '@toeverything/datasource/db-service';
|
||||
import { NewFromTemplatePortal } from './NewFromTemplatePortal';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
const MESSAGES = {
|
||||
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
|
||||
};
|
||||
interface ActionsProps {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
function DndTreeItemMoreActions(props: ActionsProps) {
|
||||
const [alert_open, set_alert_open] = React.useState(false);
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const workspaceId = props.workspaceId;
|
||||
const pageId = props.pageId;
|
||||
const blockUrl = window.location.origin + `/${workspaceId}/${pageId}`;
|
||||
|
||||
const redirect_to_page = (new_workspaceId: string, newPageId: string) => {
|
||||
navigate('/' + new_workspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handle_alert_close = () => {
|
||||
set_alert_open(false);
|
||||
};
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const open = Boolean(anchorEl);
|
||||
const handle_copy_link = () => {
|
||||
copyToClipboard(blockUrl);
|
||||
set_alert_open(true);
|
||||
handleClose();
|
||||
};
|
||||
const handle_delete = () => {
|
||||
props.onRemove();
|
||||
handleClose();
|
||||
};
|
||||
const handle_new_child_page = async () => {
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
await services.api.pageTree.addChildPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
const handle_new_prev_page = async () => {
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
await services.api.pageTree.addPrevPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
const handle_new_next_page = async () => {
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handle_duplicate_page = async () => {
|
||||
//create page
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
//add page to tree
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
//copy source page to new page
|
||||
await services.api.editorBlock.copyPage(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const redirectToPage = (newWorkspaceId: string, newPageId: string) => {
|
||||
navigate('/' + newWorkspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handleNewFromTemplate = async template => {
|
||||
const newPage = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
|
||||
await services.api.editorBlock.copyTemplateToPage(
|
||||
workspaceId,
|
||||
newPage.id,
|
||||
TemplateFactory.generatePageTemplateByGroupKeys({
|
||||
name: template.name,
|
||||
groupKeys: template.groupKeys,
|
||||
})
|
||||
);
|
||||
|
||||
redirectToPage(workspaceId, newPage.id);
|
||||
};
|
||||
|
||||
const templateList = useFlag(
|
||||
'JSONTemplateList',
|
||||
TemplateFactory.defaultTemplateList
|
||||
);
|
||||
|
||||
const templateMenuList: CascaderItemProps[] = templateList.map(
|
||||
(template, index) => {
|
||||
const item = {
|
||||
title: template.name,
|
||||
callback: () => {
|
||||
handleNewFromTemplate(template);
|
||||
},
|
||||
};
|
||||
return item;
|
||||
}
|
||||
);
|
||||
|
||||
const menuList = [
|
||||
{
|
||||
title: 'Duplicate Page',
|
||||
callback: () => {
|
||||
handle_duplicate_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
isDivide: true,
|
||||
},
|
||||
{
|
||||
title: 'New Child Page',
|
||||
callback: () => {
|
||||
handle_new_child_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'New Prev Page',
|
||||
callback: () => {
|
||||
handle_new_prev_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'New Next Page',
|
||||
callback: () => {
|
||||
handle_new_next_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'New From Template',
|
||||
subItems: templateMenuList,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
isDivide: true,
|
||||
},
|
||||
{
|
||||
title: 'Open In New Tab',
|
||||
callback: () => {
|
||||
const new_window = window.open(
|
||||
`/${workspaceId}/${pageId}`,
|
||||
'_blank'
|
||||
);
|
||||
if (new_window) {
|
||||
new_window.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
isDivide: true,
|
||||
},
|
||||
{
|
||||
title: 'Copy Link',
|
||||
callback: () => {
|
||||
handle_copy_link();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Delete',
|
||||
callback: () => {
|
||||
handle_delete();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={styles['TreeItemMoreActions']}
|
||||
onClick={handleClick}
|
||||
>
|
||||
···
|
||||
</span>
|
||||
<Cascader
|
||||
items={menuList}
|
||||
anchorEl={anchorEl}
|
||||
placement="right-start"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
></Cascader>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
open={alert_open}
|
||||
message={MESSAGES.COPY_LINK_SUCCESS}
|
||||
key={'bottomcenter'}
|
||||
autoHideDuration={2000}
|
||||
onClose={handle_alert_close}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DndTreeItemMoreActions;
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, {
|
||||
useState,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { services, TemplateFactory } from '@toeverything/datasource/db-service';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import { ViewSidebarIcon } from '@toeverything/components/common';
|
||||
import {
|
||||
MuiSnackbar as Snackbar,
|
||||
MuiPopover as Popover,
|
||||
ListButton,
|
||||
MuiDivider as Divider,
|
||||
MuiSwitch as Switch,
|
||||
styled,
|
||||
BaseButton,
|
||||
} from '@toeverything/components/ui';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
const NewFromTemplatePortalContainer = styled('div')({
|
||||
width: '320p',
|
||||
padding: '15px',
|
||||
'.textDescription': {
|
||||
height: '22px',
|
||||
lineHeight: '22px',
|
||||
marginLeft: '30px',
|
||||
fontSize: '14px',
|
||||
color: '#ccc',
|
||||
p: {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
'.switchDescription': {
|
||||
color: '#ccc',
|
||||
fontSize: '14px',
|
||||
paddingLeft: '30px',
|
||||
},
|
||||
});
|
||||
|
||||
const BtnPageSettingContainer = styled('div')({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
});
|
||||
|
||||
const MESSAGES = {
|
||||
COPY_LINK: ' Copy Link',
|
||||
INVITE: 'Add people,emails, or groups',
|
||||
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
|
||||
};
|
||||
|
||||
interface NewFromTemplatePortalProps {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
}
|
||||
function NewFromTemplatePortal(props: NewFromTemplatePortalProps) {
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserAndSpaces();
|
||||
|
||||
const workspaceId = props.workspaceId;
|
||||
const pageId = props.pageId;
|
||||
const handleAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
};
|
||||
const redirectToPage = (newWorkspaceId: string, newPageId: string) => {
|
||||
navigate('/' + newWorkspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handleNewFromTemplate = async template => {
|
||||
const newPage = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
|
||||
await services.api.editorBlock.copyTemplateToPage(
|
||||
workspaceId,
|
||||
newPage.id,
|
||||
TemplateFactory.generatePageTemplateByGroupKeys({
|
||||
name: template.name,
|
||||
groupKeys: template.groupKeys,
|
||||
})
|
||||
);
|
||||
|
||||
redirectToPage(workspaceId, newPage.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const newFromTemplateRef = useRef();
|
||||
const templateList = useFlag(
|
||||
'JSONTemplateList',
|
||||
TemplateFactory.defaultTemplateList
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div ref={newFromTemplateRef} onClick={handleClick}>
|
||||
<ListButton content="New From Template" onClick={() => {}} />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<NewFromTemplatePortalContainer>
|
||||
<>
|
||||
{templateList.map((template, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleNewFromTemplate(template);
|
||||
}}
|
||||
>
|
||||
<BaseButton>{template.name}</BaseButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</NewFromTemplatePortalContainer>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { NewFromTemplatePortal };
|
||||
184
libs/components/layout/src/workspace-sidebar/page-tree/tree-item/TreeItem.tsx
Executable file
184
libs/components/layout/src/workspace-sidebar/page-tree/tree-item/TreeItem.tsx
Executable file
@@ -0,0 +1,184 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
} from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import cx from 'clsx';
|
||||
import { CloseIcon, DocumentIcon } from '@toeverything/components/common';
|
||||
import {
|
||||
ArrowDropDownIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
|
||||
import styles from './tree-item.module.scss';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
|
||||
import MoreActions from './MoreActions';
|
||||
export type TreeItemProps = {
|
||||
/** The main text to display on this line */
|
||||
value: string;
|
||||
/** The layer number of the node, 0, 1, 2 */
|
||||
depth: number;
|
||||
/** The item in the DragOverlay is clone, the one in the normal list is not clone, and the delete icon is displayed through the clone control */
|
||||
clone?: boolean;
|
||||
pageId?: string;
|
||||
childCount?: number;
|
||||
collapsed?: boolean;
|
||||
disableInteraction?: boolean;
|
||||
disableSelection?: boolean;
|
||||
/** isDragging */
|
||||
ghost?: boolean;
|
||||
handleProps?: any;
|
||||
indicator?: boolean;
|
||||
indentationWidth: number;
|
||||
onCollapse?(): void;
|
||||
onRemove?(): void;
|
||||
/** The ref of the outermost container is often used as droppaHTMLAttributes<HTMLLIElement>ble-node; the ref of the inner dom is often used as draggable-node */
|
||||
wrapperRef?(node: HTMLLIElement): void;
|
||||
} & HTMLAttributes<HTMLLIElement>;
|
||||
|
||||
export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
(
|
||||
{
|
||||
childCount,
|
||||
clone,
|
||||
depth,
|
||||
disableSelection,
|
||||
disableInteraction,
|
||||
ghost,
|
||||
handleProps,
|
||||
indentationWidth,
|
||||
indicator,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onRemove,
|
||||
style,
|
||||
value,
|
||||
wrapperRef,
|
||||
pageId,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { workspace_id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const BooleanPageTreeItemMoreActions = useFlag(
|
||||
'BooleanPageTreeItemMoreActions',
|
||||
false
|
||||
);
|
||||
return (
|
||||
<li
|
||||
ref={wrapperRef}
|
||||
className={cx(
|
||||
styles['Wrapper'],
|
||||
clone && styles['clone'],
|
||||
ghost && styles['ghost'],
|
||||
indicator && styles['indicator'],
|
||||
disableSelection && styles['disableSelection'],
|
||||
disableInteraction && styles['disableInteraction']
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--spacing': `${indentationWidth * depth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles['TreeItem']}
|
||||
style={style}
|
||||
title={value}
|
||||
>
|
||||
<Action onClick={onCollapse}>
|
||||
{collapsed ? <ArrowRightIcon /> : <ArrowDropDownIcon />}
|
||||
</Action>
|
||||
<Action>
|
||||
<DocumentIcon />
|
||||
</Action>
|
||||
<span
|
||||
className={styles['Text']}
|
||||
{...handleProps}
|
||||
onClick={() => {
|
||||
navigate(`/${workspace_id}/${pageId}`);
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{BooleanPageTreeItemMoreActions && (
|
||||
<MoreActions
|
||||
workspaceId={workspace_id}
|
||||
pageId={pageId}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!clone && onRemove && <Remove onClick={onRemove} />}
|
||||
{clone && childCount && childCount > 1 ? (
|
||||
<span className={styles['Count']}>{childCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface ActionProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
active?: {
|
||||
fill: string;
|
||||
background: string;
|
||||
};
|
||||
// cursor?: CSSProperties['cursor'];
|
||||
cursor?: 'pointer' | 'grab';
|
||||
}
|
||||
|
||||
/** Customizable buttons */
|
||||
export function Action({
|
||||
active,
|
||||
className,
|
||||
cursor,
|
||||
style,
|
||||
...props
|
||||
}: ActionProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cx(styles['Action'], className)}
|
||||
tabIndex={0}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
// cursor,
|
||||
'--fill': active?.fill,
|
||||
'--background': active?.background,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Handle(props: ActionProps) {
|
||||
return (
|
||||
<Action cursor="grab" data-cypress="draggable-handle" {...props}>
|
||||
<ArrowDropDownIcon />
|
||||
</Action>
|
||||
);
|
||||
}
|
||||
|
||||
export function Remove(props: ActionProps) {
|
||||
return (
|
||||
<Action
|
||||
{...props}
|
||||
active={{
|
||||
fill: 'rgba(255, 70, 70, 0.95)',
|
||||
background: 'rgba(255, 70, 70, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 12 }} />
|
||||
{/* <svg width="8" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.99998 -0.000206962C2.7441 -0.000206962 2.48794 0.0972617 2.29294 0.292762L0.292945 2.29276C-0.0980552 2.68376 -0.0980552 3.31682 0.292945 3.70682L7.58591 10.9998L0.292945 18.2928C-0.0980552 18.6838 -0.0980552 19.3168 0.292945 19.7068L2.29294 21.7068C2.68394 22.0978 3.31701 22.0978 3.70701 21.7068L11 14.4139L18.2929 21.7068C18.6829 22.0978 19.317 22.0978 19.707 21.7068L21.707 19.7068C22.098 19.3158 22.098 18.6828 21.707 18.2928L14.414 10.9998L21.707 3.70682C22.098 3.31682 22.098 2.68276 21.707 2.29276L19.707 0.292762C19.316 -0.0982383 18.6829 -0.0982383 18.2929 0.292762L11 7.58573L3.70701 0.292762C3.51151 0.0972617 3.25585 -0.000206962 2.99998 -0.000206962Z" />
|
||||
</svg> */}
|
||||
</Action>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DndTreeItem } from './DndTreeItem';
|
||||
export { TreeItem } from './TreeItem';
|
||||
@@ -0,0 +1,182 @@
|
||||
.Wrapper {
|
||||
box-sizing: border-box;
|
||||
padding-left: var(--spacing);
|
||||
margin-bottom: -1px;
|
||||
list-style: none;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
|
||||
&.clone {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
margin-left: 10px;
|
||||
margin-top: 5px;
|
||||
pointer-events: none;
|
||||
|
||||
.TreeItem {
|
||||
padding-right: 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 15px 15px 0 rgba(34, 33, 81, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
&.indicator {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.TreeItem {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
height: 8px;
|
||||
border-color: #2389ff;
|
||||
background-color: #56a1f8;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: -4px;
|
||||
display: block;
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #2389ff;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
> * {
|
||||
/* Items are hidden using height and opacity to retain focus */
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.indicator) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.TreeItem > * {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TreeItem {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
background-color: #fff;
|
||||
color: #4c6275;
|
||||
|
||||
.TreeItemMoreActions {
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
&:hover {
|
||||
.TreeItemMoreActions {
|
||||
visibility: visible;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Text {
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Count {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #2389ff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.disableInteraction {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.disableSelection,
|
||||
.clone {
|
||||
.Text,
|
||||
.Count {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Collapse {
|
||||
svg {
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
|
||||
&.collapsed svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.Action {
|
||||
display: flex;
|
||||
width: 12px;
|
||||
padding: 0 15px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--action-background, rgba(0, 0, 0, 0.05));
|
||||
|
||||
svg {
|
||||
fill: #6f7b88;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
margin: auto;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
fill: #919eab;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--background, rgba(0, 0, 0, 0.05));
|
||||
|
||||
svg {
|
||||
fill: var(--fill, #788491);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), 0 0px 0px 2px #4c9ffe;
|
||||
}
|
||||
}
|
||||
25
libs/components/layout/src/workspace-sidebar/page-tree/types.ts
Executable file
25
libs/components/layout/src/workspace-sidebar/page-tree/types.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
export type TreeItem = {
|
||||
/** page id */
|
||||
id: string;
|
||||
/** page title */
|
||||
title?: string;
|
||||
/** sub pages */
|
||||
children: TreeItem[];
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export type TreeItems = TreeItem[];
|
||||
|
||||
export type FlattenedItem = TreeItem & {
|
||||
index: number;
|
||||
/** parent page id */
|
||||
parentId: string | null;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type SensorContext = MutableRefObject<{
|
||||
items: FlattenedItem[];
|
||||
offset: number;
|
||||
}>;
|
||||
215
libs/components/layout/src/workspace-sidebar/page-tree/use-page-tree.ts
Executable file
215
libs/components/layout/src/workspace-sidebar/page-tree/use-page-tree.ts
Executable file
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import type {
|
||||
DragEndEvent,
|
||||
DragMoveEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import type { DndTreeProps } from './DndTree';
|
||||
import type { FlattenedItem, TreeItem, TreeItems } from './types';
|
||||
import {
|
||||
buildTree,
|
||||
flattenTree,
|
||||
getProjection,
|
||||
removeChildrenOf,
|
||||
removeItem,
|
||||
setProperty,
|
||||
} from './utils';
|
||||
|
||||
const page_tree_atom = atom<TreeItems | undefined>([]);
|
||||
|
||||
export const usePageTree = ({ indentationWidth = 16 }: DndTreeProps = {}) => {
|
||||
const { workspace_id, page_id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [items] = useAtom(page_tree_atom);
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined);
|
||||
const [overId, setOverId] = useState<string | undefined>(undefined);
|
||||
const [offsetLeft, setOffsetLeft] = useState<number>(0);
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const flattenedTree = flattenTree(items);
|
||||
const collapsedItems = flattenedTree.reduce<string[]>(
|
||||
(acc, { children, collapsed, id }) =>
|
||||
collapsed && children.length ? [...acc, id] : acc,
|
||||
[]
|
||||
);
|
||||
return removeChildrenOf(
|
||||
flattenedTree,
|
||||
activeId ? [activeId, ...collapsedItems] : collapsedItems
|
||||
);
|
||||
}, [activeId, items]);
|
||||
|
||||
const projected = useMemo(
|
||||
() =>
|
||||
activeId && overId
|
||||
? getProjection(
|
||||
flattenedItems,
|
||||
activeId,
|
||||
overId,
|
||||
offsetLeft,
|
||||
indentationWidth
|
||||
)
|
||||
: null,
|
||||
[activeId, flattenedItems, indentationWidth, offsetLeft, overId]
|
||||
);
|
||||
|
||||
const savePageTreeData = useCallback(
|
||||
async (treeData?: TreeItem[]) => {
|
||||
await services.api.pageTree.setPageTree<TreeItem>(
|
||||
workspace_id,
|
||||
treeData || []
|
||||
);
|
||||
},
|
||||
[workspace_id]
|
||||
);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setOverId(undefined);
|
||||
setActiveId(undefined);
|
||||
setOffsetLeft(0);
|
||||
|
||||
document.body.style.setProperty('cursor', '');
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(({ active: { id: activeId } }) => {
|
||||
setActiveId(activeId);
|
||||
setOverId(activeId);
|
||||
|
||||
document.body.style.setProperty('cursor', 'grabbing');
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback(({ delta }: DragMoveEvent) => {
|
||||
setOffsetLeft(delta.x);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(({ over }) => {
|
||||
setOverId(over?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async ({ active, over }: DragEndEvent) => {
|
||||
resetState();
|
||||
|
||||
if (projected && over) {
|
||||
const { depth, parentId } = projected;
|
||||
const clonedItems: FlattenedItem[] = JSON.parse(
|
||||
JSON.stringify(flattenTree(items))
|
||||
);
|
||||
const overIndex = clonedItems.findIndex(
|
||||
({ id }) => id === over.id
|
||||
);
|
||||
const activeIndex = clonedItems.findIndex(
|
||||
({ id }) => id === active.id
|
||||
);
|
||||
const activeTreeItem = clonedItems[activeIndex];
|
||||
|
||||
clonedItems[activeIndex] = {
|
||||
...activeTreeItem,
|
||||
depth,
|
||||
parentId,
|
||||
};
|
||||
|
||||
const sortedItems = arrayMove(
|
||||
clonedItems,
|
||||
activeIndex,
|
||||
overIndex
|
||||
);
|
||||
const newItems = buildTree(sortedItems);
|
||||
|
||||
await savePageTreeData(newItems);
|
||||
}
|
||||
},
|
||||
[items, projected, resetState, savePageTreeData]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (id: string) => {
|
||||
await savePageTreeData(removeItem(items, id));
|
||||
await services.api.userConfig.removePage(workspace_id, id);
|
||||
//remove page from jwst
|
||||
await services.api.pageTree.removePage(workspace_id, id);
|
||||
if (id === page_id) {
|
||||
navigate(`/${workspace_id}`);
|
||||
}
|
||||
},
|
||||
[items, savePageTreeData, workspace_id]
|
||||
);
|
||||
|
||||
const handleAddPage = useCallback(
|
||||
async (page_id?: string) => {
|
||||
await savePageTreeData([{ id: page_id, children: [] }, ...items]);
|
||||
},
|
||||
[items, savePageTreeData]
|
||||
);
|
||||
|
||||
const handleCollapse = useCallback(
|
||||
async (id: string) => {
|
||||
await savePageTreeData(
|
||||
setProperty(items, id, 'collapsed', value => {
|
||||
return !value;
|
||||
})
|
||||
);
|
||||
},
|
||||
[items, savePageTreeData]
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
activeId,
|
||||
overId,
|
||||
offsetLeft,
|
||||
flattenedItems,
|
||||
projected,
|
||||
handleAddPage,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
handleRemove,
|
||||
handleCollapse,
|
||||
};
|
||||
};
|
||||
|
||||
export const useDndTreeAutoUpdate = () => {
|
||||
const [, set_items] = useAtom(page_tree_atom);
|
||||
const { workspace_id, page_id } = useParams();
|
||||
|
||||
const fetch_page_tree_data = useCallback(async () => {
|
||||
const pages = await services.api.pageTree.getPageTree<TreeItem>(
|
||||
workspace_id
|
||||
);
|
||||
set_items(pages);
|
||||
}, [set_items, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch_page_tree_data();
|
||||
}, [fetch_page_tree_data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!page_id) return () => {};
|
||||
let unobserve: () => void;
|
||||
const auto_update_page_tree = async () => {
|
||||
unobserve = await services.api.pageTree.observe(
|
||||
{ workspace: workspace_id, page: page_id },
|
||||
() => {
|
||||
fetch_page_tree_data();
|
||||
}
|
||||
);
|
||||
};
|
||||
auto_update_page_tree();
|
||||
|
||||
return () => {
|
||||
unobserve?.();
|
||||
};
|
||||
}, [fetch_page_tree_data, page_id, workspace_id]);
|
||||
};
|
||||
214
libs/components/layout/src/workspace-sidebar/page-tree/utils.ts
Executable file
214
libs/components/layout/src/workspace-sidebar/page-tree/utils.ts
Executable file
@@ -0,0 +1,214 @@
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import type { FlattenedItem, TreeItem, TreeItems } from './types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const iOS = /iPad|iPhone|iPod/.test(navigator.platform);
|
||||
|
||||
function getDragDepth(offset: number, indentationWidth: number) {
|
||||
return Math.round(offset / indentationWidth);
|
||||
}
|
||||
|
||||
export function getProjection(
|
||||
items: FlattenedItem[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
dragOffset: number,
|
||||
indentationWidth: number
|
||||
) {
|
||||
const overItemIndex = items.findIndex(({ id }) => id === overId);
|
||||
const activeItemIndex = items.findIndex(({ id }) => id === activeId);
|
||||
const activeItem = items[activeItemIndex];
|
||||
const newItems = arrayMove(items, activeItemIndex, overItemIndex);
|
||||
const previousItem = newItems[overItemIndex - 1];
|
||||
const nextItem = newItems[overItemIndex + 1];
|
||||
const dragDepth = getDragDepth(dragOffset, indentationWidth);
|
||||
const projectedDepth = activeItem.depth + dragDepth;
|
||||
const maxDepth = getMaxDepth({
|
||||
previousItem,
|
||||
});
|
||||
const minDepth = getMinDepth({ nextItem });
|
||||
let depth = projectedDepth;
|
||||
|
||||
if (projectedDepth >= maxDepth) {
|
||||
depth = maxDepth;
|
||||
} else if (projectedDepth < minDepth) {
|
||||
depth = minDepth;
|
||||
}
|
||||
|
||||
return { depth, maxDepth, minDepth, parentId: getParentId() };
|
||||
|
||||
function getParentId() {
|
||||
if (depth === 0 || !previousItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (depth === previousItem.depth) {
|
||||
return previousItem.parentId;
|
||||
}
|
||||
|
||||
if (depth > previousItem.depth) {
|
||||
return previousItem.id;
|
||||
}
|
||||
|
||||
const newParent = newItems
|
||||
.slice(0, overItemIndex)
|
||||
.reverse()
|
||||
.find(item => item.depth === depth)?.parentId;
|
||||
|
||||
return newParent ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxDepth({ previousItem }: { previousItem: FlattenedItem }) {
|
||||
if (previousItem) {
|
||||
return previousItem.depth + 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getMinDepth({ nextItem }: { nextItem: FlattenedItem }) {
|
||||
if (nextItem) {
|
||||
return nextItem.depth;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function flatten(
|
||||
items: TreeItems,
|
||||
parentId: string | null = null,
|
||||
depth = 0
|
||||
): FlattenedItem[] {
|
||||
return items.reduce<FlattenedItem[]>((acc, item, index) => {
|
||||
return [
|
||||
...acc,
|
||||
{ ...item, parentId, depth, index },
|
||||
...flatten(item.children, item.id, depth + 1),
|
||||
];
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function flattenTree(items: TreeItems): FlattenedItem[] {
|
||||
return flatten(items);
|
||||
}
|
||||
|
||||
export function buildTree(flattenedItems: FlattenedItem[]): TreeItems {
|
||||
const root: TreeItem = { id: 'root', children: [] };
|
||||
const nodes: Record<string, TreeItem> = { [root.id]: root };
|
||||
const items = flattenedItems.map(item => ({ ...item, children: [] }));
|
||||
|
||||
for (const item of items) {
|
||||
const { id, children } = item;
|
||||
const parentId = item.parentId ?? root.id;
|
||||
const parent = nodes[parentId] ?? findItem(items, parentId);
|
||||
|
||||
nodes[id] = { id, children };
|
||||
parent.children.push(item);
|
||||
}
|
||||
|
||||
return root.children;
|
||||
}
|
||||
|
||||
export function findItem(items: TreeItem[], itemId: string) {
|
||||
return items.find(({ id }) => id === itemId);
|
||||
}
|
||||
|
||||
export function findItemDeep(
|
||||
items: TreeItems,
|
||||
itemId: string
|
||||
): TreeItem | undefined {
|
||||
for (const item of items) {
|
||||
const { id, children } = item;
|
||||
|
||||
if (id === itemId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (children.length) {
|
||||
const child = findItemDeep(children, itemId);
|
||||
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Recursively remove id objects from items */
|
||||
export function removeItem(items: TreeItems, id: string) {
|
||||
const newItems = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.children.length) {
|
||||
item.children = removeItem(item.children, id);
|
||||
}
|
||||
|
||||
newItems.push(item);
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}
|
||||
|
||||
/** Recursively modify the attribute value of the id node in the tree, returning a new array */
|
||||
export function setProperty<T extends keyof TreeItem>(
|
||||
items: TreeItems,
|
||||
id: string,
|
||||
property: T,
|
||||
setter: (value: TreeItem[T]) => TreeItem[T]
|
||||
) {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
item[property] = setter(item[property]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.children.length) {
|
||||
item.children = setProperty(item.children, id, property, setter);
|
||||
}
|
||||
}
|
||||
|
||||
return [...items];
|
||||
}
|
||||
|
||||
function countChildren(items: TreeItem[], count = 0): number {
|
||||
return items.reduce((acc, { children }) => {
|
||||
if (children.length) {
|
||||
return countChildren(children, acc + 1);
|
||||
}
|
||||
|
||||
return acc + 1;
|
||||
}, count);
|
||||
}
|
||||
|
||||
export function getChildCount(items: TreeItems, id: string) {
|
||||
if (!id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const item = findItemDeep(items, id);
|
||||
|
||||
return item ? countChildren(item.children) : 0;
|
||||
}
|
||||
|
||||
export function removeChildrenOf(items: FlattenedItem[], ids: string[]) {
|
||||
const excludeParentIds = [...ids];
|
||||
|
||||
return items.filter(item => {
|
||||
if (item.parentId && excludeParentIds.includes(item.parentId)) {
|
||||
if (item.children.length) {
|
||||
excludeParentIds.push(item.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user