diff --git a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx index 183adc6094..5aa7dff804 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx @@ -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 ( @@ -50,35 +63,45 @@ export function Page(props: PageProps) { > - + -
- {dailyNotesFlag && ( + {activeTab === TabMap.get(TAB_TITLE.PAGES).value && ( +
+ {dailyNotesFlag && ( +
+ + + +
+ )}
- - + +
- )} -
- - - +
+ + {page_id ? : null} + +
-
- - {page_id ? : null} - -
-
+ )} + + {activeTab === TabMap.get(TAB_TITLE.TOC).value && ( + TOC + )} - + ); } @@ -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(); @@ -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(); } diff --git a/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx b/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx index 74a8dcc5ae..aea0c88374 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx @@ -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([ - ['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; -const Tabs = () => { - const [activeValue, setActiveTab] = useState(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 ( {[...TabMap.entries()].map(([k, { value, disabled = false }]) => { - const isActive = activeValue === value; + const isActive = activeTab === value; return ( { className={isActive ? 'active' : ''} isActive={isActive} isDisabled={disabled} - onClick={() => onClick(value)} + onClick={() => onTabChange(value)} > {k} diff --git a/apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx new file mode 100644 index 0000000000..a4c4fba27e --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx @@ -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 ( + onClick(id)} + > + {text} + + ); +}; + +const renderTOCContent = tocDataSource => { + return ( + <> + {tocDataSource.map(tocItem => { + if (tocItem?.length) { + return renderTOCContent(tocItem); + } + + const { id, type, text } = tocItem; + + return ; + })} + + ); +}; + +export const TOC = (props: Props) => { + const { editor } = props; + const { page_id } = useParams(); + const [tocDataSource, setTocDataSource] = useState([]); + 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 ( + +
{renderTOCContent(tocDataSource)}
+
+ ); +}; diff --git a/apps/ligo-virgo/src/pages/workspace/docs/components/toc/index.ts b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/index.ts new file mode 100644 index 0000000000..e603af8242 --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/index.ts @@ -0,0 +1 @@ +export { TOC } from './TOC'; diff --git a/apps/ligo-virgo/src/pages/workspace/docs/utils/toc.ts b/apps/ligo-virgo/src/pages/workspace/docs/utils/toc.ts new file mode 100644 index 0000000000..9b550e9bbe --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/utils/toc.ts @@ -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 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 => { + /* 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 }; diff --git a/libs/components/editor-core/src/editor/editor.ts b/libs/components/editor-core/src/editor/editor.ts index e7f6458a5f..74243ba6aa 100644 --- a/libs/components/editor-core/src/editor/editor.ts +++ b/libs/components/editor-core/src/editor/editor.ts @@ -333,6 +333,12 @@ export class Editor implements Virgo { return await this.getBlock({ workspace: this.workspace, id: blockId }); } + async getBlockByIds(ids: string[]): Promise[]> { + 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 {