mirror of https://github.com/fantasticit/think.git
tiptap: add table-of-contents
This commit is contained in:
parent
8e10998859
commit
354881505b
|
@ -2,8 +2,15 @@ import { Extension } from '@tiptap/core';
|
||||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||||
import { Schema, Fragment } from 'prosemirror-model';
|
import { Schema, Fragment } from 'prosemirror-model';
|
||||||
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
|
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
|
||||||
import { handleFileEvent, isInCode, LANGUAGES, isTitleNode } from 'tiptap/prose-utils';
|
import {
|
||||||
import { copyNode, isMarkdown, normalizeMarkdown } from 'tiptap/prose-utils';
|
handleFileEvent,
|
||||||
|
isInCode,
|
||||||
|
LANGUAGES,
|
||||||
|
isTitleNode,
|
||||||
|
copyNode,
|
||||||
|
isMarkdown,
|
||||||
|
normalizeMarkdown,
|
||||||
|
} from 'tiptap/prose-utils';
|
||||||
import { safeJSONParse } from 'helpers/json';
|
import { safeJSONParse } from 'helpers/json';
|
||||||
|
|
||||||
interface IPasteOptions {
|
interface IPasteOptions {
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import { TableOfContentsWrapper } from 'tiptap/core/wrappers/table-of-contents';
|
||||||
|
import { isTitleNode, findNode } from 'tiptap/prose-utils';
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
tableOfContents: {
|
||||||
|
setTableOfContents: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
onHasOneBeforeInsert?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableOfContents = Node.create<Options>({
|
||||||
|
name: 'tableOfContents',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
onHasOneBeforeInsert: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'toc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['toc', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(TableOfContentsWrapper);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setTableOfContents:
|
||||||
|
() =>
|
||||||
|
({ commands, editor, view }) => {
|
||||||
|
const nodes = findNode(editor, this.name);
|
||||||
|
|
||||||
|
if (nodes.length) {
|
||||||
|
this.options.onHasOneBeforeInsert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleNode = view.props.state.doc.content.firstChild;
|
||||||
|
|
||||||
|
if (isTitleNode(titleNode)) {
|
||||||
|
const pos = ((titleNode.firstChild && titleNode.firstChild.nodeSize) || 0) + 1;
|
||||||
|
return commands.insertContentAt(pos, { type: this.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: ['heading'],
|
||||||
|
attributes: {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,47 @@
|
||||||
|
.toc {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
background: rgb(black 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.025rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
content: '目录';
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--3 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--4 {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--5 {
|
||||||
|
padding-left: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--6 {
|
||||||
|
padding-left: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Collapsible, Button } from '@douyinfe/semi-ui';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
import { useToggle } from 'hooks/use-toggle';
|
||||||
|
|
||||||
|
export const TableOfContentsWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [visible, toggleVisible] = useToggle(false);
|
||||||
|
|
||||||
|
const maskStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
visible
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
WebkitMaskImage:
|
||||||
|
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||||
|
},
|
||||||
|
[visible]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(() => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
setItems(headings);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(handleUpdate, [handleUpdate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.on('update', handleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.off('update', handleUpdate);
|
||||||
|
};
|
||||||
|
}, [editor, handleUpdate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className={styles.toc}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Collapsible isOpen={visible} collapseHeight={60} style={{ ...maskStyle }}>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={index} className={styles.item} style={{ paddingLeft: `${item.level - 2}rem` }}>
|
||||||
|
<a href={`#${item.id}`}>{item.text}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Collapsible>
|
||||||
|
<Button theme="light" type="tertiary" size="small" onClick={toggleVisible}>
|
||||||
|
{visible ? '收起' : '展开'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Toast } from '@douyinfe/semi-ui';
|
||||||
// 基础扩展
|
// 基础扩展
|
||||||
import { Document } from 'tiptap/core/extensions/document';
|
import { Document } from 'tiptap/core/extensions/document';
|
||||||
import { BackgroundColor } from 'tiptap/core/extensions/background-color';
|
import { BackgroundColor } from 'tiptap/core/extensions/background-color';
|
||||||
|
@ -58,12 +59,11 @@ import { Mind } from 'tiptap/core/extensions/mind';
|
||||||
import { QuickInsert } from 'tiptap/core/extensions/quick-insert';
|
import { QuickInsert } from 'tiptap/core/extensions/quick-insert';
|
||||||
import { SearchNReplace } from 'tiptap/core/extensions/search';
|
import { SearchNReplace } from 'tiptap/core/extensions/search';
|
||||||
import { Status } from 'tiptap/core/extensions/status';
|
import { Status } from 'tiptap/core/extensions/status';
|
||||||
|
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
|
||||||
// markdown 支持
|
// markdown 支持
|
||||||
import { markdownToProsemirror } from 'tiptap/markdown/markdown-to-prosemirror';
|
import { markdownToProsemirror } from 'tiptap/markdown/markdown-to-prosemirror';
|
||||||
import { markdownToHTML } from 'tiptap/markdown/markdown-to-prosemirror/markdown-to-html';
|
import { markdownToHTML } from 'tiptap/markdown/markdown-to-prosemirror/markdown-to-html';
|
||||||
import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown';
|
import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown';
|
||||||
import { debounce } from 'helpers/debounce';
|
|
||||||
|
|
||||||
const DocumentWithTitle = Document.extend({
|
const DocumentWithTitle = Document.extend({
|
||||||
content: 'title block+',
|
content: 'title block+',
|
||||||
|
@ -141,6 +141,11 @@ export const CollaborationKit = [
|
||||||
QuickInsert,
|
QuickInsert,
|
||||||
SearchNReplace,
|
SearchNReplace,
|
||||||
Status,
|
Status,
|
||||||
|
TableOfContents.configure({
|
||||||
|
onHasOneBeforeInsert: () => {
|
||||||
|
Toast.info('目录已存在');
|
||||||
|
},
|
||||||
|
}),
|
||||||
Title,
|
Title,
|
||||||
DocumentWithTitle,
|
DocumentWithTitle,
|
||||||
];
|
];
|
||||||
|
|
|
@ -30,6 +30,11 @@ const COMMANDS = [
|
||||||
{
|
{
|
||||||
title: '通用',
|
title: '通用',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <IconCodeBlock />,
|
||||||
|
label: '目录',
|
||||||
|
action: (editor) => editor.chain().focus().setTableOfContents().run(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <IconTable />,
|
icon: <IconTable />,
|
||||||
label: '表格',
|
label: '表格',
|
||||||
|
|
|
@ -124,6 +124,17 @@ export const QUICK_INSERT_ITEMS = [
|
||||||
command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(),
|
command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: '目录',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconTable />
|
||||||
|
目录
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setTableOfContents().run(),
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: '表格',
|
key: '表格',
|
||||||
label: (
|
label: (
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { CodeBlock } from 'tiptap/core/extensions/code-block';
|
||||||
import { Iframe } from 'tiptap/core/extensions/iframe';
|
import { Iframe } from 'tiptap/core/extensions/iframe';
|
||||||
import { Mind } from 'tiptap/core/extensions/mind';
|
import { Mind } from 'tiptap/core/extensions/mind';
|
||||||
import { Table } from 'tiptap/core/extensions/table';
|
import { Table } from 'tiptap/core/extensions/table';
|
||||||
|
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
|
||||||
import { Katex } from 'tiptap/core/extensions/katex';
|
import { Katex } from 'tiptap/core/extensions/katex';
|
||||||
import { DocumentReference } from 'tiptap/core/extensions/document-reference';
|
import { DocumentReference } from 'tiptap/core/extensions/document-reference';
|
||||||
import { DocumentChildren } from 'tiptap/core/extensions/document-children';
|
import { DocumentChildren } from 'tiptap/core/extensions/document-children';
|
||||||
|
@ -35,6 +36,7 @@ const OTHER_BUBBLE_MENU_TYPES = [
|
||||||
Iframe.name,
|
Iframe.name,
|
||||||
Mind.name,
|
Mind.name,
|
||||||
Table.name,
|
Table.name,
|
||||||
|
TableOfContents.name,
|
||||||
DocumentReference.name,
|
DocumentReference.name,
|
||||||
DocumentChildren.name,
|
DocumentChildren.name,
|
||||||
Katex.name,
|
Katex.name,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
import { Node } from 'prosemirror-model';
|
import { Node } from 'prosemirror-model';
|
||||||
import { EditorState } from 'prosemirror-state';
|
import { EditorState } from 'prosemirror-state';
|
||||||
|
|
||||||
|
@ -66,3 +67,23 @@ export function isInTitle(state: EditorState): boolean {
|
||||||
export function isInCallout(state: EditorState): boolean {
|
export function isInCallout(state: EditorState): boolean {
|
||||||
return isInCustomNode(state, 'callout');
|
return isInCustomNode(state, 'callout');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const findNode = (editor: Editor, name: string) => {
|
||||||
|
const content = editor.getJSON();
|
||||||
|
const queue = [content];
|
||||||
|
const res = [];
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
const node = queue.shift();
|
||||||
|
|
||||||
|
if (node.type === name) {
|
||||||
|
res.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.content && node.content.length) {
|
||||||
|
queue.push(...node.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue