diff --git a/README.md b/README.md index 718d32c94d..19e0db1307 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx index a2440a1757..6d9d2fac90 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..b2c0aa693e --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/toc/TOC.tsx @@ -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 ( + 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 { 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 ( + + {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/getPageContentById.ts b/apps/ligo-virgo/src/pages/workspace/docs/utils/getPageContentById.ts new file mode 100644 index 0000000000..b1d16cb9d6 --- /dev/null +++ b/apps/ligo-virgo/src/pages/workspace/docs/utils/getPageContentById.ts @@ -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 }; 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 { diff --git a/libs/datasource/db-service/src/services/index.ts b/libs/datasource/db-service/src/services/index.ts index 315ee84496..c39119fe68 100644 --- a/libs/datasource/db-service/src/services/index.ts +++ b/libs/datasource/db-service/src/services/index.ts @@ -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', diff --git a/libs/datasource/db-service/src/services/workspace/toc.ts b/libs/datasource/db-service/src/services/workspace/toc.ts new file mode 100644 index 0000000000..16bec3da18 --- /dev/null +++ b/libs/datasource/db-service/src/services/workspace/toc.ts @@ -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 { + // 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(); + }; + } +}