mirror of https://github.com/fantasticit/think.git
client: intergrate tocs is editor
parent
548d811130
commit
4ded780906
|
@ -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} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.tocsWrap}>
|
||||
<Tocs editor={editor} getContainer={getTocsContainer} />
|
||||
</div>
|
||||
{protals}
|
||||
</main>
|
||||
|
||||
{editable && menubar && (
|
||||
<BackTop
|
||||
target={() => $mainContainer.current}
|
||||
style={{ right: isMobile ? 16 : 100, bottom: 65 }}
|
||||
visibilityHeight={200}
|
||||
/>
|
||||
)}
|
||||
<BackTop
|
||||
target={() => $mainContainer.current}
|
||||
style={{ right: isMobile ? 16 : 100, bottom: 65 }}
|
||||
visibilityHeight={200}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
.dot {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
<div className={styles.wrapper}>
|
||||
<Anchor
|
||||
railTheme={collapsed ? 'muted' : 'tertiary'}
|
||||
maxHeight={'calc(100vh - 360px)'}
|
||||
getContainer={getContainer}
|
||||
maxWidth={collapsed ? 56 : 150}
|
||||
>
|
||||
{collapsed
|
||||
? headings.map((toc) => {
|
||||
return (
|
||||
<Tooltip key={toc.text} content={toc.text} position="right">
|
||||
<div className={styles.dot}></div>
|
||||
</Tooltip>
|
||||
<Anchor.Link
|
||||
key={toc.text}
|
||||
href={`#${toc.id}`}
|
||||
title={
|
||||
<Tooltip key={toc.text} content={toc.text} position="right">
|
||||
<span className={styles.dot}>●</span>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
})
|
||||
: nestedHeadings.map((toc) => <Toc key={toc.text} toc={toc} collapsed={collapsed} />)}
|
||||
</Anchor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue