mirror of https://github.com/fantasticit/think.git
tiptap: add table-of-contents
parent
8e10998859
commit
354881505b
|
@ -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 {
|
||||
|
|
|
@ -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 { 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,
|
||||
];
|
||||
|
|
|
@ -30,6 +30,11 @@ const COMMANDS = [
|
|||
{
|
||||
title: '通用',
|
||||
},
|
||||
{
|
||||
icon: <IconCodeBlock />,
|
||||
label: '目录',
|
||||
action: (editor) => editor.chain().focus().setTableOfContents().run(),
|
||||
},
|
||||
{
|
||||
icon: <IconTable />,
|
||||
label: '表格',
|
||||
|
|
|
@ -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: (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue