client: intergrate tocs is editor

This commit is contained in:
fantasticit 2022-05-30 12:15:11 +08:00
parent 548d811130
commit 4ded780906
6 changed files with 182 additions and 141 deletions

View File

@ -10,7 +10,7 @@ import { useDocumentStyle } from 'hooks/use-document-style';
import { useNetwork } from 'hooks/use-network'; import { useNetwork } from 'hooks/use-network';
import { IsOnMobile } from 'hooks/use-on-mobile'; import { IsOnMobile } from 'hooks/use-on-mobile';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Collaboration } from 'tiptap/core/extensions/collaboration'; import { Collaboration } from 'tiptap/core/extensions/collaboration';
import { CollaborationCursor } from 'tiptap/core/extensions/collaboration-cursor'; import { CollaborationCursor } from 'tiptap/core/extensions/collaboration-cursor';
import { Tocs } from 'tiptap/editor/tocs'; import { Tocs } from 'tiptap/editor/tocs';
@ -84,11 +84,11 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
}, },
[editable, user, onTitleUpdate, hocuspocusProvider] [editable, user, onTitleUpdate, hocuspocusProvider]
); );
const [headings, setHeadings] = useState([]);
const { width, fontSize } = useDocumentStyle(); const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => { const editorWrapClassNames = useMemo(() => {
return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth; return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth;
}, [width]); }, [width]);
const getTocsContainer = useCallback(() => $mainContainer.current, []);
useImperativeHandle(ref, () => editor); useImperativeHandle(ref, () => editor);
@ -156,22 +156,6 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
}; };
}, [isMobile]); }, [isMobile]);
useEffect(() => {
if (!editor) return;
const collectHeadings = (headings) => {
if (headings && headings.length) {
setHeadings(headings);
}
};
editor.eventEmitter.on('TableOfContents', collectHeadings);
return () => {
editor.eventEmitter.off('TableOfContents', collectHeadings);
};
}, [editor]);
return ( return (
<> <>
{(!online || status === 'disconnected') && ( {(!online || status === 'disconnected') && (
@ -209,21 +193,16 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
</div> </div>
)} )}
</div> </div>
{!isMobile && editor && headings.length ? ( <div className={styles.tocsWrap}>
<div className={styles.tocsWrap}> <Tocs editor={editor} getContainer={getTocsContainer} />
<Tocs tocs={headings} editor={editor} /> </div>
</div>
) : null}
{protals} {protals}
</main> </main>
<BackTop
{editable && menubar && ( target={() => $mainContainer.current}
<BackTop style={{ right: isMobile ? 16 : 100, bottom: 65 }}
target={() => $mainContainer.current} visibilityHeight={200}
style={{ right: isMobile ? 16 : 100, bottom: 65 }} />
visibilityHeight={200}
/>
)}
</> </>
); );
}); });

View File

@ -0,0 +1,33 @@
.wrap {
display: flex;
width: 100%;
height: 100%;
min-height: 240px;
overflow: hidden;
flex-direction: column;
> main {
position: relative;
display: flex;
overflow: auto;
flex: 1;
justify-content: center;
flex-wrap: nowrap;
.contentWrap {
width: 100%;
&.isStandardWidth {
max-width: 750px;
}
&.isFullWidth {
max-width: 100%;
}
}
.tocsWrap {
position: relative;
}
}
}

View File

@ -1,20 +1,24 @@
import { BackTop } from '@douyinfe/semi-ui';
import { isMobile } from 'helpers/env';
import { safeJSONParse } from 'helpers/json'; import { safeJSONParse } from 'helpers/json';
import React, { useMemo } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { EditorContent, useEditor } from '../react'; import { EditorContent, useEditor } from '../react';
import { Tocs } from '../tocs';
import { CollaborationKit } from './kit'; import { CollaborationKit } from './kit';
import styles from './reader.module.scss';
interface IProps { interface IProps {
content: string; content: string;
} }
export const ReaderEditor: React.FC<IProps> = ({ content }) => { export const ReaderEditor: React.FC<IProps> = ({ content }) => {
const $mainContainer = useRef<HTMLDivElement>();
const json = useMemo(() => { const json = useMemo(() => {
const c = safeJSONParse(content); const c = safeJSONParse(content);
const json = c.default || c; const json = c.default || c;
return json; return json;
}, [content]); }, [content]);
const editor = useEditor( const editor = useEditor(
{ {
editable: false, editable: false,
@ -23,6 +27,23 @@ export const ReaderEditor: React.FC<IProps> = ({ content }) => {
}, },
[json] [json]
); );
const getTocsContainer = useCallback(() => $mainContainer.current, []);
return <EditorContent editor={editor} />; return (
<div className={styles.wrap}>
<main ref={$mainContainer} id={'js-tocs-container'}>
<div className={styles.contentWrap}>
<EditorContent editor={editor} />
</div>
<div className={styles.tocsWrap}>
<Tocs editor={editor} getContainer={getTocsContainer} />
</div>
</main>
<BackTop
target={() => $mainContainer.current}
style={{ right: isMobile ? 16 : 100, bottom: 65 }}
visibilityHeight={200}
/>
</div>
);
}; };

View File

@ -1,38 +1,10 @@
.wrapper { .wrapper {
position: fixed; position: fixed;
padding-top: 1rem; padding-top: 2rem;
padding-right: 1rem; padding-right: 1rem;
padding-left: 2rem; padding-left: 2rem;
> header { .dot {
margin-bottom: 12px; font-size: 8px;
line-height: 22px;
color: var(--main-text-color);
opacity: 0;
}
&:hover {
> header {
opacity: 1;
}
}
.dotWrap {
display: flex;
flex-direction: column;
padding-left: 12px;
.dot {
position: relative;
width: 10px;
height: 10px;
cursor: pointer;
background-color: var(--semi-color-text-3);
border-radius: 50%;
& + .dot {
margin-top: 10px;
}
}
} }
} }

View File

@ -1,20 +1,19 @@
import { IconDoubleChevronLeft, IconDoubleChevronRight } from '@douyinfe/semi-icons'; import { Anchor, Tooltip } from '@douyinfe/semi-ui';
import { Anchor, Button, Tooltip } from '@douyinfe/semi-ui';
import { Editor } from '@tiptap/core';
import { throttle } from 'helpers/throttle'; import { throttle } from 'helpers/throttle';
import { flattenTree2Array } from 'helpers/tree';
import { useDocumentStyle, Width } from 'hooks/use-document-style';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents'; import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
import { Editor } from 'tiptap/editor/react';
import { findNode } from 'tiptap/prose-utils'; import { findNode } from 'tiptap/prose-utils';
import styles from './index.module.scss'; import styles from './index.module.scss';
import { flattenHeadingsToTree } from './util';
interface IToc { interface IHeading {
level: number; level: number;
id: string; id: string;
text: string; text: string;
children?: IHeading[];
} }
const Toc = ({ toc, collapsed }) => { const Toc = ({ toc, collapsed }) => {
@ -39,93 +38,112 @@ const Toc = ({ toc, collapsed }) => {
); );
}; };
const FULL_WIDTH = 1200; const FULL_WIDTH = 1000;
export const Tocs: React.FC<{ tocs: Array<IToc>; editor: Editor }> = ({ tocs = [], editor }) => { export const Tocs: React.FC<{ editor: Editor; getContainer: () => HTMLElement }> = ({ editor, getContainer }) => {
const [hasToc, toggleHasToc] = useToggle(false);
const [collapsed, toggleCollapsed] = useToggle(true); const [collapsed, toggleCollapsed] = useToggle(true);
const { width } = useDocumentStyle(); const [headings, setHeadings] = useState<IHeading[]>([]);
const [nestedHeadings, setNestedHeadings] = useState<IHeading[]>([]);
const getContainer = useCallback(() => {
return document.querySelector(`#js-tocs-container`);
}, []);
useEffect(() => { useEffect(() => {
if (width === Width.fullWidth) { const el = getContainer();
toggleCollapsed(true);
} else {
toggleCollapsed(false);
}
}, [width, toggleCollapsed]);
useEffect(() => { if (!el) return;
const listener = () => {
const nodes = findNode(editor, TableOfContents.name);
const hasTocNow = !!(nodes && nodes.length);
if (hasTocNow !== hasToc) {
toggleHasToc(hasTocNow);
}
};
editor.on('transaction', listener);
return () => {
editor.off('transaction', listener);
};
}, [editor, hasToc, toggleHasToc]);
useEffect(() => {
const el = document.querySelector(`#js-tocs-container`) as HTMLDivElement;
const handler = throttle(() => { const handler = throttle(() => {
toggleCollapsed(el.offsetWidth <= FULL_WIDTH); toggleCollapsed(el.offsetWidth <= FULL_WIDTH);
}, 200); }, 200);
handler(); handler();
const observer = new MutationObserver(handler);
window.addEventListener('resize', handler); observer.observe(el, { attributes: true, childList: true, subtree: true });
return () => { return () => {
window.removeEventListener('resize', handler); observer.disconnect();
}; };
}, [toggleCollapsed]); }, [getContainer, toggleCollapsed]);
const getTocs = useCallback(() => {
if (!editor) return;
const nodes = findNode(editor, TableOfContents.name);
if (!nodes || !nodes.length) {
setHeadings([]);
setNestedHeadings([]);
return;
}
const headings = [];
const transaction = editor.state.tr;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
const id = `heading-${headings.length + 1}`;
if (node.attrs.id !== id) {
transaction.setNodeMarkup(pos, undefined, {
...node.attrs,
id,
});
}
headings.push({
level: node.attrs.level,
text: node.textContent,
id,
});
}
});
transaction.setMeta('addToHistory', false);
transaction.setMeta('preventUpdate', true);
editor.view.dispatch(transaction);
setHeadings(headings);
setNestedHeadings(flattenHeadingsToTree(headings));
}, [editor]);
useEffect(() => {
if (!editor) {
return;
}
editor.on('update', getTocs);
return () => {
editor.off('update', getTocs);
};
}, [editor, getTocs]);
useEffect(() => {
getTocs();
}, [getTocs]);
if (!headings || !headings.length) return null;
return ( return (
<div className={styles.wrapper} style={{ display: hasToc ? 'block' : 'none' }}> <div className={styles.wrapper}>
<header> <Anchor
<Button railTheme={collapsed ? 'muted' : 'tertiary'}
type="tertiary" maxHeight={'calc(100vh - 360px)'}
theme="borderless" getContainer={getContainer}
icon={!collapsed ? <IconDoubleChevronRight /> : <IconDoubleChevronLeft />} maxWidth={collapsed ? 56 : 150}
onClick={toggleCollapsed} >
></Button> {collapsed
</header> ? headings.map((toc) => {
<main>
{collapsed ? (
<div
className={styles.dotWrap}
style={{
maxHeight: 'calc(100vh - 360px)',
}}
>
{flattenTree2Array(tocs).map((toc) => {
return ( return (
<Tooltip key={toc.text} content={toc.text} position="right"> <Anchor.Link
<div className={styles.dot}></div> key={toc.text}
</Tooltip> href={`#${toc.id}`}
title={
<Tooltip key={toc.text} content={toc.text} position="right">
<span className={styles.dot}></span>
</Tooltip>
}
/>
); );
})} })
</div> : nestedHeadings.map((toc) => <Toc key={toc.text} toc={toc} collapsed={collapsed} />)}
) : ( </Anchor>
<Anchor
railTheme={collapsed ? 'muted' : 'tertiary'}
maxHeight={'calc(100vh - 360px)'}
getContainer={getContainer}
maxWidth={collapsed ? 56 : 180}
>
{tocs.length && tocs.map((toc) => <Toc key={toc.text} toc={toc} collapsed={collapsed} />)}
</Anchor>
)}
</main>
</div> </div>
); );
}; };

View File

@ -0,0 +1,18 @@
export const flattenHeadingsToTree = (tocs) => {
const result = [];
const levels = [result];
tocs.forEach((o) => {
let offset = -1;
let parent = levels[o.level + offset];
while (!parent) {
offset -= 1;
parent = levels[o.level + offset];
}
parent.push({ ...o, children: (levels[o.level] = []) });
});
return result;
};