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 { 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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 { 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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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