init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions
@@ -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} />
</>
);
};
@@ -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',
};
});
@@ -0,0 +1 @@
export { SettingsSidebar } from './SettingsSidebar';
@@ -0,0 +1,5 @@
export const useSettingsSidebar = () => {
return {
show: false,
};
};