tiptap: add table-of-contents

This commit is contained in:
fantasticit 2022-05-10 15:00:38 +08:00
parent 8e10998859
commit 354881505b
9 changed files with 270 additions and 4 deletions

View File

@ -2,8 +2,15 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import { Schema, Fragment } from 'prosemirror-model';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { handleFileEvent, isInCode, LANGUAGES, isTitleNode } from 'tiptap/prose-utils';
import { copyNode, isMarkdown, normalizeMarkdown } from 'tiptap/prose-utils';
import {
handleFileEvent,
isInCode,
LANGUAGES,
isTitleNode,
copyNode,
isMarkdown,
normalizeMarkdown,
} from 'tiptap/prose-utils';
import { safeJSONParse } from 'helpers/json';
interface IPasteOptions {

View File

@ -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,
},
},
},
];
},
});

View File

@ -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;
}
}
}

View File

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

View File

@ -1,3 +1,4 @@
import { Toast } from '@douyinfe/semi-ui';
// 基础扩展
import { Document } from 'tiptap/core/extensions/document';
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 { SearchNReplace } from 'tiptap/core/extensions/search';
import { Status } from 'tiptap/core/extensions/status';
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
// markdown 支持
import { markdownToProsemirror } from 'tiptap/markdown/markdown-to-prosemirror';
import { markdownToHTML } from 'tiptap/markdown/markdown-to-prosemirror/markdown-to-html';
import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown';
import { debounce } from 'helpers/debounce';
const DocumentWithTitle = Document.extend({
content: 'title block+',
@ -141,6 +141,11 @@ export const CollaborationKit = [
QuickInsert,
SearchNReplace,
Status,
TableOfContents.configure({
onHasOneBeforeInsert: () => {
Toast.info('目录已存在');
},
}),
Title,
DocumentWithTitle,
];

View File

@ -30,6 +30,11 @@ const COMMANDS = [
{
title: '通用',
},
{
icon: <IconCodeBlock />,
label: '目录',
action: (editor) => editor.chain().focus().setTableOfContents().run(),
},
{
icon: <IconTable />,
label: '表格',

View File

@ -124,6 +124,17 @@ export const QUICK_INSERT_ITEMS = [
command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(),
},
{
key: '目录',
label: (
<Space>
<IconTable />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setTableOfContents().run(),
},
{
key: '表格',
label: (

View File

@ -21,6 +21,7 @@ import { CodeBlock } from 'tiptap/core/extensions/code-block';
import { Iframe } from 'tiptap/core/extensions/iframe';
import { Mind } from 'tiptap/core/extensions/mind';
import { Table } from 'tiptap/core/extensions/table';
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
import { Katex } from 'tiptap/core/extensions/katex';
import { DocumentReference } from 'tiptap/core/extensions/document-reference';
import { DocumentChildren } from 'tiptap/core/extensions/document-children';
@ -35,6 +36,7 @@ const OTHER_BUBBLE_MENU_TYPES = [
Iframe.name,
Mind.name,
Table.name,
TableOfContents.name,
DocumentReference.name,
DocumentChildren.name,
Katex.name,

View File

@ -1,3 +1,4 @@
import { Editor } from '@tiptap/core';
import { Node } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
@ -66,3 +67,23 @@ export function isInTitle(state: EditorState): boolean {
export function isInCallout(state: EditorState): boolean {
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;
};