feat: 1. add toc;

This commit is contained in:
mitsuha
2022-08-23 15:37:44 +08:00
parent 7b4999225a
commit ab070daa7e
9 changed files with 350 additions and 38 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ Our latest news can be found on [Twitter](https://twitter.com/AffineOfficial), [
# Contact Us
You may contact us by emailing to: contact@toeverything.info
You may contact us by emailing to: contact@toeverything.info
# The Philosophy of AFFiNE
@@ -22,16 +22,29 @@ import { type BlockEditor } from '@toeverything/components/editor-core';
import { useFlag } from '@toeverything/datasource/feature-flags';
import { CollapsiblePageTree } from './collapsible-page-tree';
import { Tabs } from './components/tabs';
import { TabMap, TAB_TITLE } from './components/tabs/Tabs';
import { TOC } from './components/toc';
import { WorkspaceName } from './workspace-name';
type PageProps = {
workspace: string;
};
export function Page(props: PageProps) {
const [activeTab, setActiveTab] = useState(
TabMap.get(TAB_TITLE.PAGES).value
);
const { page_id } = useParams();
const { showSpaceSidebar, fixedDisplay, setSpaceSidebarVisible } =
useShowSpaceSidebar();
const dailyNotesFlag = useFlag('BooleanDailyNotes', false);
const editorRef = useRef(null);
const onTabChange = v => setActiveTab(v);
const getEditor = editor => {
editorRef.current = editor;
};
return (
<LigoApp>
@@ -50,35 +63,45 @@ export function Page(props: PageProps) {
>
<WorkspaceName />
<Tabs />
<Tabs activeTab={activeTab} onTabChange={onTabChange} />
<WorkspaceSidebarContent>
<div>
{dailyNotesFlag && (
{activeTab === TabMap.get(TAB_TITLE.PAGES).value && (
<div>
{dailyNotesFlag && (
<div>
<CollapsibleTitle title="Daily Notes">
<CalendarHeatmap />
</CollapsibleTitle>
</div>
)}
<div>
<CollapsibleTitle title="Daily Notes">
<CalendarHeatmap />
<CollapsibleTitle
title="ACTIVITIES"
initialOpen={false}
>
<Activities />
</CollapsibleTitle>
</div>
)}
<div>
<CollapsibleTitle
title="ACTIVITIES"
initialOpen={false}
>
<Activities />
</CollapsibleTitle>
<div>
<CollapsiblePageTree title="PAGES">
{page_id ? <PageTree /> : null}
</CollapsiblePageTree>
</div>
</div>
<div>
<CollapsiblePageTree title="PAGES">
{page_id ? <PageTree /> : null}
</CollapsiblePageTree>
</div>
</div>
)}
{activeTab === TabMap.get(TAB_TITLE.TOC).value && (
<TOC editor={editorRef.current}>TOC</TOC>
)}
</WorkspaceSidebarContent>
</WorkspaceSidebar>
</LigoLeftContainer>
<EditorContainer workspace={props.workspace} pageId={page_id} />
<EditorContainer
workspace={props.workspace}
pageId={page_id}
getEditor={getEditor}
/>
</LigoApp>
);
}
@@ -86,9 +109,11 @@ export function Page(props: PageProps) {
const EditorContainer = ({
pageId,
workspace,
getEditor,
}: {
pageId: string;
workspace: string;
getEditor: (editor: BlockEditor) => void;
}) => {
const [lockScroll, setLockScroll] = useState(false);
const [scrollContainer, setScrollContainer] = useState<HTMLElement>();
@@ -105,6 +130,8 @@ const EditorContainer = ({
const obv = new ResizeObserver(e => {
setPageClientWidth(e[0].contentRect.width);
});
getEditor(editorRef.current);
obv.observe(scrollContainer);
return () => obv.disconnect();
}
@@ -1,6 +1,5 @@
import { styled } from '@toeverything/components/ui';
import type { ValueOf } from '@toeverything/utils';
import { useState } from 'react';
const StyledTabs = styled('div')(({ theme }) => {
return {
@@ -56,32 +55,35 @@ const StyledTabTitle = styled('div')<{
}
`;
const TAB_TITLE = {
PAGES: 'pages',
GALLERY: 'gallery',
TOC: 'toc',
export const TAB_TITLE = {
PAGES: 'PAGES',
GALLERY: 'GALLERY',
TOC: 'TOC',
} as const;
const TabMap = new Map<TabKey, { value: TabValue; disabled?: boolean }>([
['PAGES', { value: 'pages' }],
['GALLERY', { value: 'gallery', disabled: true }],
['TOC', { value: 'toc' }],
export const TabMap = new Map<
TabValue,
{ value: TabValue; disabled?: boolean }
>([
[TAB_TITLE.PAGES, { value: TAB_TITLE.PAGES }],
[TAB_TITLE.GALLERY, { value: TAB_TITLE.GALLERY, disabled: true }],
[TAB_TITLE.TOC, { value: TAB_TITLE.TOC }],
]);
type TabKey = keyof typeof TAB_TITLE;
type TabValue = ValueOf<typeof TAB_TITLE>;
const Tabs = () => {
const [activeValue, setActiveTab] = useState<TabValue>(TAB_TITLE.PAGES);
interface Props {
activeTab: TabValue;
onTabChange: (v: TabValue) => void;
}
const onClick = (v: TabValue) => {
setActiveTab(v);
};
const Tabs = (props: Props) => {
const { activeTab, onTabChange } = props;
return (
<StyledTabs>
{[...TabMap.entries()].map(([k, { value, disabled = false }]) => {
const isActive = activeValue === value;
const isActive = activeTab === value;
return (
<StyledTabTitle
@@ -89,7 +91,7 @@ const Tabs = () => {
className={isActive ? 'active' : ''}
isActive={isActive}
isDisabled={disabled}
onClick={() => onClick(value)}
onClick={() => onTabChange(value)}
>
{k}
</StyledTabTitle>
@@ -0,0 +1,168 @@
import { BlockEditor } from '@toeverything/components/editor-core';
import { styled } from '@toeverything/components/ui';
import { services } from '@toeverything/datasource/db-service';
import type { ReactNode } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { useParams } from 'react-router';
import {
BLOCK_TYPES,
getPageContentById,
} from '../../utils/getPageContentById';
const StyledTOC = styled('div')(() => {
return {};
});
const StyledTOCItem = styled('a')<{ type?: string; isActive?: boolean }>(
({ type, isActive }) => {
const common = {
height: '32px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
color: isActive ? '#3E6FDB' : '#4C6275',
};
if (type === BLOCK_TYPES.HEADING1) {
return {
...common,
padding: '0 12px',
fontWeight: '600',
fontSize: '16px',
};
}
if (type === BLOCK_TYPES.HEADING2) {
return {
...common,
padding: '0 32px',
fontSize: '14px',
};
}
if (type === BLOCK_TYPES.HEADING3) {
return {
...common,
padding: '0 52px',
fontSize: '12px',
};
}
if (type === BLOCK_TYPES.GROUP) {
return {
...common,
margin: '6px 0px',
height: '46px',
padding: '6px 12px',
fontWeight: '600',
fontSize: '16px',
borderTop: '0.5px solid #E0E6EB',
borderBottom: '0.5px solid #E0E6EB',
color: isActive ? '#3E6FDB' : '#98ACBD',
};
}
return {};
}
);
const StyledItem = styled('div')(props => {
return {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});
const TOCContext = createContext(null);
interface Props {
children: ReactNode;
editor: BlockEditor;
}
const TOCItem = props => {
const { activeBlockId, onClick } = useContext(TOCContext);
const { id, type, text } = props;
const isActive = id === activeBlockId;
return (
<StyledTOCItem
key={id}
isActive={isActive}
type={type}
onClick={() => onClick(id)}
>
<StyledItem>{text}</StyledItem>
</StyledTOCItem>
);
};
const renderTOCContent = tocDataSource => {
return (
<>
{tocDataSource.map(tocItem => {
if (tocItem?.length) {
return renderTOCContent(tocItem);
}
const { id, type, text } = tocItem;
return <TOCItem key={id} id={id} type={type} text={text} />;
})}
</>
);
};
export const TOC = (props: Props) => {
const { editor } = props;
const { workspace_id, page_id } = useParams();
const [tocDataSource, setTocDataSource] = useState([]);
const [activeBlockId, setActiveBlockId] = useState('blockId');
const updateTocDataSource = useCallback(async () => {
const tocDataSource = await getPageContentById(editor, page_id);
setTocDataSource(tocDataSource);
}, [editor, page_id]);
useEffect(() => {
(async () => await updateTocDataSource())();
}, [updateTocDataSource]);
useEffect(() => {
let unobserve: () => void;
const observe = async () => {
unobserve = await services.api.tocService.observe(
{ workspace: workspace_id, pageId: page_id },
updateTocDataSource
);
};
void observe();
return () => {
unobserve?.();
};
}, [updateTocDataSource, workspace_id]);
const onClick = async (blockId?: string) => {
if (blockId === activeBlockId) {
return;
}
console.log(blockId);
setActiveBlockId(blockId);
await editor.scrollManager.scrollIntoViewByBlockId(blockId);
};
return (
<TOCContext.Provider value={{ activeBlockId, onClick }}>
<StyledTOC>{renderTOCContent(tocDataSource)}</StyledTOC>
</TOCContext.Provider>
);
};
@@ -0,0 +1 @@
export { TOC } from './TOC';
@@ -0,0 +1,64 @@
import type { BlockEditor } from '@toeverything/components/editor-core';
export const BLOCK_TYPES = {
GROUP: 'group',
HEADING1: 'heading1',
HEADING2: 'heading2',
HEADING3: 'heading3',
};
const getContentByAsyncBlocks = async (asyncBlocks = []) => {
/* maybe should recast it to tail recursion */
return await Promise.all(
asyncBlocks.map(async asyncBlock => {
const asyncBlocks = await asyncBlock.children();
if (asyncBlocks?.length) {
return getContentByAsyncBlocks(asyncBlocks);
}
const { id, type } = asyncBlock;
if (Object.values(BLOCK_TYPES).includes(type)) {
const properties = await asyncBlock.getProperties();
return {
id: id,
type,
text: properties?.text?.value?.[0]?.text || '',
};
}
return null;
})
);
};
const getPageContentById = async (editor: BlockEditor, pageId: string) => {
const { children = [] } = (await editor.queryByPageId(pageId))?.[0] || {};
const asyncBlocks = (await editor.getBlockByIds(children)) || [];
const tocContents = await getContentByAsyncBlocks(asyncBlocks);
return tocContents
.reduce((tocGroupContent, tocContent, index) => {
const { id, type } = asyncBlocks[index];
const groupContent = {
id,
type,
text: 'Untitled Group',
};
tocGroupContent.push(
!tocContent.flat(Infinity).filter(Boolean).length
? groupContent
: tocContent
);
return tocGroupContent;
}, [])
.flat(Infinity)
.filter(Boolean);
};
export { getPageContentById };
@@ -333,6 +333,12 @@ export class Editor implements Virgo {
return await this.getBlock({ workspace: this.workspace, id: blockId });
}
async getBlockByIds(ids: string[]): Promise<Awaited<AsyncBlock | null>[]> {
return await Promise.all(
ids.map(id => this.getBlock({ workspace: this.workspace, id }))
);
}
/**
* TODO: to be optimized
* get block`s dom by block`s id
@@ -477,6 +483,13 @@ export class Editor implements Virgo {
return await services.api.editorBlock.query(this.workspace, query);
}
async queryByPageId(pageId: string) {
return await services.api.editorBlock.get({
workspace: this.workspace,
ids: [pageId],
});
}
/** Hooks */
public getHooks(): HooksRunner & PluginHooks {
@@ -5,6 +5,7 @@ import { Database } from './database';
import { EditorBlock } from './editor-block';
import { FileService } from './file';
import { PageTree } from './workspace/page-tree';
import { TOC } from './workspace/toc';
import { UserConfig } from './workspace/user-config';
export {
@@ -48,6 +49,7 @@ export interface DbServicesMap {
userConfig: UserConfig;
file: FileService;
commentService: CommentService;
tocService: TOC;
}
interface RegisterDependencyConfigWithName extends RegisterDependencyConfig {
@@ -76,6 +78,13 @@ const dbServiceConfig: RegisterDependencyConfigWithName[] = [
value: PageTree,
dependencies: [{ token: Database }],
},
{
type: 'class',
callName: 'tocService',
token: TOC,
value: TOC,
dependencies: [{ token: Database }],
},
{
type: 'class',
callName: 'userConfig',
@@ -0,0 +1,28 @@
import { ServiceBaseClass } from '../base';
import { ReturnUnobserve } from '../database/observer';
import { ObserveCallback } from './page-tree';
export class TOC extends ServiceBaseClass {
private onActivePageChange?: () => void;
async observe(
{ workspace, pageId }: { workspace: string; pageId: string },
callback: ObserveCallback
): Promise<ReturnUnobserve> {
// not only use observe, but also addChildrenListener is OK 🎉🎉🎉
// const pageBlock = await this.getBlock(workspace, pageId);
//
// this.onActivePageChange?.();
// pageBlock?.addChildrenListener('onPageChildrenChange', () => {
// callback();
// });
//
// this.onActivePageChange = () => pageBlock?.removeChildrenListener('onPageChildrenChange');
const unobserve = await this._observe(workspace, pageId, callback);
return () => {
unobserve();
};
}
}