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 { IsOnMobile } from 'hooks/use-on-mobile';
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 { CollaborationCursor } from 'tiptap/core/extensions/collaboration-cursor';
import { Tocs } from 'tiptap/editor/tocs';
@ -84,11 +84,11 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
},
[editable, user, onTitleUpdate, hocuspocusProvider]
);
const [headings, setHeadings] = useState([]);
const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => {
return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth;
}, [width]);
const getTocsContainer = useCallback(() => $mainContainer.current, []);
useImperativeHandle(ref, () => editor);
@ -156,22 +156,6 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
};
}, [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 (
<>
{(!online || status === 'disconnected') && (
@ -209,21 +193,16 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
</div>
)}
</div>
{!isMobile && editor && headings.length ? (
<div className={styles.tocsWrap}>
<Tocs tocs={headings} editor={editor} />
<Tocs editor={editor} getContainer={getTocsContainer} />
</div>
) : null}
{protals}
</main>
{editable && menubar && (
<BackTop
target={() => $mainContainer.current}
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 React, { useMemo } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { EditorContent, useEditor } from '../react';
import { Tocs } from '../tocs';
import { CollaborationKit } from './kit';
import styles from './reader.module.scss';
interface IProps {
content: string;
}
export const ReaderEditor: React.FC<IProps> = ({ content }) => {
const $mainContainer = useRef<HTMLDivElement>();
const json = useMemo(() => {
const c = safeJSONParse(content);
const json = c.default || c;
return json;
}, [content]);
const editor = useEditor(
{
editable: false,
@ -23,6 +27,23 @@ export const ReaderEditor: React.FC<IProps> = ({ content }) => {
},
[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 {
position: fixed;
padding-top: 1rem;
padding-top: 2rem;
padding-right: 1rem;
padding-left: 2rem;
> header {
margin-bottom: 12px;
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;
}
}
font-size: 8px;
}
}

View File

@ -1,20 +1,19 @@
import { IconDoubleChevronLeft, IconDoubleChevronRight } from '@douyinfe/semi-icons';
import { Anchor, Button, Tooltip } from '@douyinfe/semi-ui';
import { Editor } from '@tiptap/core';
import { Anchor, Tooltip } from '@douyinfe/semi-ui';
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 React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
import { Editor } from 'tiptap/editor/react';
import { findNode } from 'tiptap/prose-utils';
import styles from './index.module.scss';
import { flattenHeadingsToTree } from './util';
interface IToc {
interface IHeading {
level: number;
id: string;
text: string;
children?: IHeading[];
}
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 }) => {
const [hasToc, toggleHasToc] = useToggle(false);
export const Tocs: React.FC<{ editor: Editor; getContainer: () => HTMLElement }> = ({ editor, getContainer }) => {
const [collapsed, toggleCollapsed] = useToggle(true);
const { width } = useDocumentStyle();
const getContainer = useCallback(() => {
return document.querySelector(`#js-tocs-container`);
}, []);
const [headings, setHeadings] = useState<IHeading[]>([]);
const [nestedHeadings, setNestedHeadings] = useState<IHeading[]>([]);
useEffect(() => {
if (width === Width.fullWidth) {
toggleCollapsed(true);
} else {
toggleCollapsed(false);
}
}, [width, toggleCollapsed]);
const el = getContainer();
useEffect(() => {
const listener = () => {
const nodes = findNode(editor, TableOfContents.name);
const hasTocNow = !!(nodes && nodes.length);
if (hasTocNow !== hasToc) {
toggleHasToc(hasTocNow);
}
};
if (!el) return;
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(() => {
toggleCollapsed(el.offsetWidth <= FULL_WIDTH);
}, 200);
handler();
window.addEventListener('resize', handler);
const observer = new MutationObserver(handler);
observer.observe(el, { attributes: true, childList: true, subtree: true });
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 (
<div className={styles.wrapper} style={{ display: hasToc ? 'block' : 'none' }}>
<header>
<Button
type="tertiary"
theme="borderless"
icon={!collapsed ? <IconDoubleChevronRight /> : <IconDoubleChevronLeft />}
onClick={toggleCollapsed}
></Button>
</header>
<main>
{collapsed ? (
<div
className={styles.dotWrap}
style={{
maxHeight: 'calc(100vh - 360px)',
}}
>
{flattenTree2Array(tocs).map((toc) => {
return (
<Tooltip key={toc.text} content={toc.text} position="right">
<div className={styles.dot}></div>
</Tooltip>
);
})}
</div>
) : (
<div className={styles.wrapper}>
<Anchor
railTheme={collapsed ? 'muted' : 'tertiary'}
maxHeight={'calc(100vh - 360px)'}
getContainer={getContainer}
maxWidth={collapsed ? 56 : 180}
maxWidth={collapsed ? 56 : 150}
>
{tocs.length && tocs.map((toc) => <Toc key={toc.text} toc={toc} collapsed={collapsed} />)}
{collapsed
? headings.map((toc) => {
return (
<Anchor.Link
key={toc.text}
href={`#${toc.id}`}
title={
<Tooltip key={toc.text} content={toc.text} position="right">
<span className={styles.dot}></span>
</Tooltip>
}
/>
);
})
: nestedHeadings.map((toc) => <Toc key={toc.text} toc={toc} collapsed={collapsed} />)}
</Anchor>
)}
</main>
</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;
};