diff --git a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx index 183adc6094..ba1897cc4a 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx @@ -22,16 +22,23 @@ 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 onTabChange = v => setActiveTab(v); return ( @@ -50,31 +57,37 @@ 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 && ( + + )} @@ -105,6 +118,7 @@ const EditorContainer = ({ const obv = new ResizeObserver(e => { setPageClientWidth(e[0].contentRect.width); }); + 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..eb0f138357 --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx @@ -0,0 +1,184 @@ +import type { Virgo } from '@toeverything/components/editor-core'; +import { styled } from '@toeverything/components/ui'; +import { useCurrentEditors } from '@toeverything/datasource/state'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useParams } from 'react-router'; +import { BLOCK_TYPES } from './toc-enum'; +import { + destroyEventList, + getContentByAsyncBlocks, + getPageTOC, +} from './toc-util'; +import type { ListenerMap, TOCType } from './types'; + +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); + +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 = () => { + const { page_id } = useParams(); + const [tocDataSource, setTocDataSource] = useState([]); + const [activeBlockId, setActiveBlockId] = useState(''); + + /* store page/block unmount-listener */ + const listenerMapRef = useRef(new Map()); + + const { currentEditors } = useCurrentEditors(); + const editor = currentEditors[page_id] as Virgo; + + const updateTocDataSource = useCallback(async () => { + if (!editor) { + return null; + } + + const listenerMap = listenerMapRef.current; + + /* 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, + listenerMap + ); + + /* toc: flat content */ + const tocDataSource = getPageTOC(asyncBlocks, tocContents); + setTocDataSource(tocDataSource); + }, [editor, page_id]); + + /* init toc and add page/block update-listener & unmount-listener */ + useEffect(() => { + (async () => { + await updateTocDataSource(); + })(); + + /* remove listener when unmount component */ + return () => destroyEventList(listenerMapRef.current); + }, [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/components/toc/toc-enum.ts b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/toc-enum.ts new file mode 100644 index 0000000000..5c9ad0c59e --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/toc-enum.ts @@ -0,0 +1,6 @@ +export enum BLOCK_TYPES { + GROUP = 'group', + HEADING1 = 'heading1', + HEADING2 = 'heading2', + HEADING3 = 'heading3', +} diff --git a/apps/ligo-virgo/src/pages/workspace/docs/components/toc/toc-util.ts b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/toc-util.ts new file mode 100644 index 0000000000..aa1154a944 --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/toc-util.ts @@ -0,0 +1,96 @@ +import { AsyncBlock } from '@toeverything/components/editor-core'; +import { BLOCK_TYPES } from './toc-enum'; +import type { ListenerMap, TOCType } from './types'; + +/* ๐Ÿ˜ž๐Ÿ˜žsorry, I don't know how to define unlimited dimensions array */ +const getContentByAsyncBlocks = async ( + asyncBlocks: AsyncBlock[] = [], + callback: () => void, + listenerMap: ListenerMap +): 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; + + switch (type) { + case BLOCK_TYPES.GROUP: + case BLOCK_TYPES.HEADING1: + case BLOCK_TYPES.HEADING2: + case BLOCK_TYPES.HEADING3: { + const properties = await asyncBlock?.getProperties(); + + return { + id, + type, + text: properties?.text?.value?.[0]?.text || '', + }; + } + default: + 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 = (listenerMap: ListenerMap) => { + const eventListeners = listenerMap.values(); + listenerMap.clear(); + + for (const eventListener of eventListeners) { + eventListener?.(); + } +}; + +export { getPageTOC, getContentByAsyncBlocks, destroyEventList }; diff --git a/apps/ligo-virgo/src/pages/workspace/docs/components/toc/types.ts b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/types.ts new file mode 100644 index 0000000000..9d84eec52a --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/types.ts @@ -0,0 +1,7 @@ +export type TOCType = { + id: string; + type: string; + text: string; +}; + +export type ListenerMap = Map void>; diff --git a/libs/components/editor-core/src/editor/editor.ts b/libs/components/editor-core/src/editor/editor.ts index ed77d2fd40..089221993a 100644 --- a/libs/components/editor-core/src/editor/editor.ts +++ b/libs/components/editor-core/src/editor/editor.ts @@ -346,6 +346,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 @@ -490,6 +496,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 { diff --git a/libs/components/editor-core/src/editor/types.ts b/libs/components/editor-core/src/editor/types.ts index f2621164b6..86c795b615 100644 --- a/libs/components/editor-core/src/editor/types.ts +++ b/libs/components/editor-core/src/editor/types.ts @@ -9,7 +9,10 @@ * 6. Dependencies between plugins are not supported for the time being */ import type { PatchNode } from '@toeverything/components/ui'; -import type { BlockFlavors } from '@toeverything/datasource/db-service'; +import type { + BlockFlavors, + ReturnEditorBlock, +} from '@toeverything/datasource/db-service'; import type { IdList, SelectionInfo, SelectionManager } from './selection'; import { Point } from '@toeverything/utils'; @@ -67,6 +70,8 @@ export interface Virgo { ) => Promise; getRootBlockId: () => string; getBlockById(blockId: string): Promise; + getBlockByIds(blockId: string[]): Promise<(AsyncBlock | null)[]>; + queryByPageId(pageId: string): Promise<(ReturnEditorBlock | null)[]>; setHotKeysScope(scope?: string): void; getBlockList: () => Promise; getBlockListByLevelOrder: () => Promise; diff --git a/libs/datasource/state/src/page.ts b/libs/datasource/state/src/page.ts index 093ee78398..e1706e508e 100644 --- a/libs/datasource/state/src/page.ts +++ b/libs/datasource/state/src/page.ts @@ -1,5 +1,4 @@ import { atom, useAtom } from 'jotai'; -import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; // import { Virgo } from '@toeverything/components/editor-core'; @@ -14,12 +13,13 @@ export const useCurrentEditors = () => { const { pathname } = useLocation(); const [currentEditors, setCurrentEditors] = useAtom(_currentEditors); - useEffect(() => { - if (!workspaceId || !pageId) return; - if (pathname.split('/').length >= 3) { - setCurrentEditors({}); - } - }, [pageId, pathname, setCurrentEditors, workspaceId]); + /* not useful: 2022.8.25 */ + // useEffect(() => { + // if (!workspaceId || !pageId) return; + // if (pathname.split('/').length >= 3) { + // setCurrentEditors({}); + // } + // }, [pageId, pathname, setCurrentEditors, workspaceId]); return { currentEditors,