mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 10:42:00 +03:00
feature: 1. add toc;
This commit is contained in:
commit
13fe35ad61
@ -22,16 +22,29 @@ import { type BlockEditor } from '@toeverything/components/editor-core';
|
|||||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||||
import { CollapsiblePageTree } from './collapsible-page-tree';
|
import { CollapsiblePageTree } from './collapsible-page-tree';
|
||||||
import { Tabs } from './components/tabs';
|
import { Tabs } from './components/tabs';
|
||||||
|
import { TabMap, TAB_TITLE } from './components/tabs/Tabs';
|
||||||
|
import { TOC } from './components/toc';
|
||||||
import { WorkspaceName } from './workspace-name';
|
import { WorkspaceName } from './workspace-name';
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
workspace: string;
|
workspace: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Page(props: PageProps) {
|
export function Page(props: PageProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState(
|
||||||
|
TabMap.get(TAB_TITLE.PAGES).value
|
||||||
|
);
|
||||||
const { page_id } = useParams();
|
const { page_id } = useParams();
|
||||||
const { showSpaceSidebar, fixedDisplay, setSpaceSidebarVisible } =
|
const { showSpaceSidebar, fixedDisplay, setSpaceSidebarVisible } =
|
||||||
useShowSpaceSidebar();
|
useShowSpaceSidebar();
|
||||||
const dailyNotesFlag = useFlag('BooleanDailyNotes', false);
|
const dailyNotesFlag = useFlag('BooleanDailyNotes', false);
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
|
||||||
|
const onTabChange = v => setActiveTab(v);
|
||||||
|
|
||||||
|
const getEditor = editor => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LigoApp>
|
<LigoApp>
|
||||||
@ -50,35 +63,45 @@ export function Page(props: PageProps) {
|
|||||||
>
|
>
|
||||||
<WorkspaceName />
|
<WorkspaceName />
|
||||||
|
|
||||||
<Tabs />
|
<Tabs activeTab={activeTab} onTabChange={onTabChange} />
|
||||||
|
|
||||||
<WorkspaceSidebarContent>
|
<WorkspaceSidebarContent>
|
||||||
<div>
|
{activeTab === TabMap.get(TAB_TITLE.PAGES).value && (
|
||||||
{dailyNotesFlag && (
|
<div>
|
||||||
|
{dailyNotesFlag && (
|
||||||
|
<div>
|
||||||
|
<CollapsibleTitle title="Daily Notes">
|
||||||
|
<CalendarHeatmap />
|
||||||
|
</CollapsibleTitle>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<CollapsibleTitle title="Daily Notes">
|
<CollapsibleTitle
|
||||||
<CalendarHeatmap />
|
title="ACTIVITIES"
|
||||||
|
initialOpen={false}
|
||||||
|
>
|
||||||
|
<Activities />
|
||||||
</CollapsibleTitle>
|
</CollapsibleTitle>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
<div>
|
<CollapsiblePageTree title="PAGES">
|
||||||
<CollapsibleTitle
|
{page_id ? <PageTree /> : null}
|
||||||
title="ACTIVITIES"
|
</CollapsiblePageTree>
|
||||||
initialOpen={false}
|
</div>
|
||||||
>
|
|
||||||
<Activities />
|
|
||||||
</CollapsibleTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<CollapsiblePageTree title="PAGES">
|
|
||||||
{page_id ? <PageTree /> : null}
|
{activeTab === TabMap.get(TAB_TITLE.TOC).value && (
|
||||||
</CollapsiblePageTree>
|
<TOC editor={editorRef.current}>TOC</TOC>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</WorkspaceSidebarContent>
|
</WorkspaceSidebarContent>
|
||||||
</WorkspaceSidebar>
|
</WorkspaceSidebar>
|
||||||
</LigoLeftContainer>
|
</LigoLeftContainer>
|
||||||
<EditorContainer workspace={props.workspace} pageId={page_id} />
|
<EditorContainer
|
||||||
|
workspace={props.workspace}
|
||||||
|
pageId={page_id}
|
||||||
|
getEditor={getEditor}
|
||||||
|
/>
|
||||||
</LigoApp>
|
</LigoApp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -86,9 +109,11 @@ export function Page(props: PageProps) {
|
|||||||
const EditorContainer = ({
|
const EditorContainer = ({
|
||||||
pageId,
|
pageId,
|
||||||
workspace,
|
workspace,
|
||||||
|
getEditor,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
|
getEditor: (editor: BlockEditor) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [lockScroll, setLockScroll] = useState(false);
|
const [lockScroll, setLockScroll] = useState(false);
|
||||||
const [scrollContainer, setScrollContainer] = useState<HTMLElement>();
|
const [scrollContainer, setScrollContainer] = useState<HTMLElement>();
|
||||||
@ -105,6 +130,8 @@ const EditorContainer = ({
|
|||||||
const obv = new ResizeObserver(e => {
|
const obv = new ResizeObserver(e => {
|
||||||
setPageClientWidth(e[0].contentRect.width);
|
setPageClientWidth(e[0].contentRect.width);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getEditor(editorRef.current);
|
||||||
obv.observe(scrollContainer);
|
obv.observe(scrollContainer);
|
||||||
return () => obv.disconnect();
|
return () => obv.disconnect();
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { styled } from '@toeverything/components/ui';
|
import { styled } from '@toeverything/components/ui';
|
||||||
import type { ValueOf } from '@toeverything/utils';
|
import type { ValueOf } from '@toeverything/utils';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
const StyledTabs = styled('div')(({ theme }) => {
|
const StyledTabs = styled('div')(({ theme }) => {
|
||||||
return {
|
return {
|
||||||
@ -56,32 +55,35 @@ const StyledTabTitle = styled('div')<{
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TAB_TITLE = {
|
export const TAB_TITLE = {
|
||||||
PAGES: 'pages',
|
PAGES: 'PAGES',
|
||||||
GALLERY: 'gallery',
|
GALLERY: 'GALLERY',
|
||||||
TOC: 'toc',
|
TOC: 'TOC',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const TabMap = new Map<TabKey, { value: TabValue; disabled?: boolean }>([
|
export const TabMap = new Map<
|
||||||
['PAGES', { value: 'pages' }],
|
TabValue,
|
||||||
['GALLERY', { value: 'gallery', disabled: true }],
|
{ value: TabValue; disabled?: boolean }
|
||||||
['TOC', { value: 'toc' }],
|
>([
|
||||||
|
[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>;
|
type TabValue = ValueOf<typeof TAB_TITLE>;
|
||||||
|
|
||||||
const Tabs = () => {
|
interface Props {
|
||||||
const [activeValue, setActiveTab] = useState<TabValue>(TAB_TITLE.PAGES);
|
activeTab: TabValue;
|
||||||
|
onTabChange: (v: TabValue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const onClick = (v: TabValue) => {
|
const Tabs = (props: Props) => {
|
||||||
setActiveTab(v);
|
const { activeTab, onTabChange } = props;
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledTabs>
|
<StyledTabs>
|
||||||
{[...TabMap.entries()].map(([k, { value, disabled = false }]) => {
|
{[...TabMap.entries()].map(([k, { value, disabled = false }]) => {
|
||||||
const isActive = activeValue === value;
|
const isActive = activeTab === value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledTabTitle
|
<StyledTabTitle
|
||||||
@ -89,7 +91,7 @@ const Tabs = () => {
|
|||||||
className={isActive ? 'active' : ''}
|
className={isActive ? 'active' : ''}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
onClick={() => onClick(value)}
|
onClick={() => onTabChange(value)}
|
||||||
>
|
>
|
||||||
{k}
|
{k}
|
||||||
</StyledTabTitle>
|
</StyledTabTitle>
|
||||||
|
176
apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx
Normal file
176
apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { BlockEditor } from '@toeverything/components/editor-core';
|
||||||
|
import { styled } from '@toeverything/components/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import {
|
||||||
|
BLOCK_TYPES,
|
||||||
|
destroyEventList,
|
||||||
|
getContentByAsyncBlocks,
|
||||||
|
getPageTOC,
|
||||||
|
listenerMap,
|
||||||
|
type TocType,
|
||||||
|
} from '../../utils/toc';
|
||||||
|
|
||||||
|
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 { page_id } = useParams();
|
||||||
|
const [tocDataSource, setTocDataSource] = useState<TocType[]>([]);
|
||||||
|
const [activeBlockId, setActiveBlockId] = useState('');
|
||||||
|
|
||||||
|
const updateTocDataSource = useCallback(async () => {
|
||||||
|
/* page listener: trigger update-notice when add new group */
|
||||||
|
const pageAsyncBlock = (await editor.getBlockByIds([page_id]))?.[0];
|
||||||
|
if (!listenerMap.has(pageAsyncBlock.id)) {
|
||||||
|
listenerMap.set(
|
||||||
|
pageAsyncBlock.id,
|
||||||
|
pageAsyncBlock.onUpdate(updateTocDataSource)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* block listener: trigger update-notice when change block content */
|
||||||
|
const { children = [] } =
|
||||||
|
(await editor.queryByPageId(page_id))?.[0] || {};
|
||||||
|
const asyncBlocks = (await editor.getBlockByIds(children)) || [];
|
||||||
|
const { tocContents } = await getContentByAsyncBlocks(
|
||||||
|
asyncBlocks,
|
||||||
|
updateTocDataSource
|
||||||
|
);
|
||||||
|
|
||||||
|
/* toc: flat content */
|
||||||
|
const tocDataSource = getPageTOC(asyncBlocks, tocContents);
|
||||||
|
setTocDataSource(tocDataSource);
|
||||||
|
|
||||||
|
/* remove listener when unmount component */
|
||||||
|
return destroyEventList;
|
||||||
|
}, [editor, page_id]);
|
||||||
|
|
||||||
|
/* init toc and add page/block update-listener & unmount-listener */
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await updateTocDataSource();
|
||||||
|
})();
|
||||||
|
}, [updateTocDataSource]);
|
||||||
|
|
||||||
|
const onClick = async (blockId?: string) => {
|
||||||
|
if (blockId === activeBlockId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveBlockId(blockId);
|
||||||
|
await editor.scrollManager.scrollIntoViewByBlockId(blockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TOCContext.Provider value={{ activeBlockId, onClick }}>
|
||||||
|
<div>{renderTOCContent(tocDataSource)}</div>
|
||||||
|
</TOCContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export { TOC } from './TOC';
|
105
apps/ligo-virgo/src/pages/workspace/docs/utils/toc.ts
Normal file
105
apps/ligo-virgo/src/pages/workspace/docs/utils/toc.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { AsyncBlock } from '@toeverything/components/editor-core';
|
||||||
|
|
||||||
|
export type TocType = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLOCK_TYPES = {
|
||||||
|
GROUP: 'group',
|
||||||
|
HEADING1: 'heading1',
|
||||||
|
HEADING2: 'heading2',
|
||||||
|
HEADING3: 'heading3',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* store page/block unmount-listener */
|
||||||
|
export const listenerMap = new Map<string, () => void>();
|
||||||
|
|
||||||
|
/* 😞😞sorry, I don't know how to define unlimited dimensions array */
|
||||||
|
const getContentByAsyncBlocks = async (
|
||||||
|
asyncBlocks: AsyncBlock[] = [],
|
||||||
|
callback: () => void
|
||||||
|
): Promise<{
|
||||||
|
tocContents: any[];
|
||||||
|
}> => {
|
||||||
|
const collect = async (asyncBlocks): Promise<any[]> => {
|
||||||
|
/* maybe should recast it to tail recursion */
|
||||||
|
return await Promise.all(
|
||||||
|
asyncBlocks.map(async (asyncBlock: AsyncBlock) => {
|
||||||
|
const asyncBlocks = await asyncBlock.children();
|
||||||
|
|
||||||
|
if (asyncBlocks?.length) {
|
||||||
|
return collect(asyncBlocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* add only once event listener for every block */
|
||||||
|
if (!listenerMap.has(asyncBlock.id)) {
|
||||||
|
/* get update notice */
|
||||||
|
const destroyHandler = asyncBlock.onUpdate(callback);
|
||||||
|
|
||||||
|
/* collect destroy handlers */
|
||||||
|
listenerMap.set(asyncBlock.id, destroyHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, type } = asyncBlock;
|
||||||
|
if (Object.values(BLOCK_TYPES).includes(type)) {
|
||||||
|
const properties = await asyncBlock.getProperties();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text: properties?.text?.value?.[0]?.text || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tocContents: await collect(asyncBlocks),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get flat toc
|
||||||
|
* @param asyncBlocks
|
||||||
|
* @param tocContents
|
||||||
|
*/
|
||||||
|
const getPageTOC = (asyncBlocks: AsyncBlock[], tocContents): TocType[] => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* destroy page/block update-listener */
|
||||||
|
const destroyEventList = (): boolean => {
|
||||||
|
const eventListeners = listenerMap.values();
|
||||||
|
listenerMap.clear();
|
||||||
|
|
||||||
|
for (const eventListener of eventListeners) {
|
||||||
|
eventListener?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getPageTOC, getContentByAsyncBlocks, destroyEventList };
|
@ -333,6 +333,12 @@ export class Editor implements Virgo {
|
|||||||
return await this.getBlock({ workspace: this.workspace, id: blockId });
|
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
|
* TODO: to be optimized
|
||||||
* get block`s dom by block`s id
|
* 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);
|
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 */
|
/** Hooks */
|
||||||
|
|
||||||
public getHooks(): HooksRunner & PluginHooks {
|
public getHooks(): HooksRunner & PluginHooks {
|
||||||
|
Loading…
Reference in New Issue
Block a user