refactor: refactor the tiptap code

This commit is contained in:
fantasticit 2022-03-19 15:58:14 +08:00
parent 0353d20dcf
commit c94fe79804
118 changed files with 1720 additions and 1669 deletions

View File

@ -32,6 +32,7 @@
"@tiptap/extension-hard-break": "^2.0.0-beta.30", "@tiptap/extension-hard-break": "^2.0.0-beta.30",
"@tiptap/extension-heading": "^2.0.0-beta.26", "@tiptap/extension-heading": "^2.0.0-beta.26",
"@tiptap/extension-highlight": "^2.0.0-beta.33", "@tiptap/extension-highlight": "^2.0.0-beta.33",
"@tiptap/extension-horizontal-rule": "^2.0.0-beta.31",
"@tiptap/extension-image": "^2.0.0-beta.25", "@tiptap/extension-image": "^2.0.0-beta.25",
"@tiptap/extension-italic": "^2.0.0-beta.25", "@tiptap/extension-italic": "^2.0.0-beta.25",
"@tiptap/extension-link": "^2.0.0-beta.36", "@tiptap/extension-link": "^2.0.0-beta.36",
@ -51,6 +52,7 @@
"@tiptap/extension-text-style": "^2.0.0-beta.23", "@tiptap/extension-text-style": "^2.0.0-beta.23",
"@tiptap/extension-underline": "^2.0.0-beta.23", "@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/react": "^2.0.0-beta.107", "@tiptap/react": "^2.0.0-beta.107",
"@traptitech/markdown-it-katex": "^3.5.0",
"axios": "^0.25.0", "axios": "^0.25.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.1", "copy-to-clipboard": "^3.3.1",
@ -59,6 +61,12 @@
"interactjs": "^1.10.11", "interactjs": "^1.10.11",
"katex": "^0.15.2", "katex": "^0.15.2",
"lowlight": "^2.5.0", "lowlight": "^2.5.0",
"markdown-it": "^12.3.2",
"markdown-it-anchor": "^8.4.1",
"markdown-it-footnote": "^3.0.3",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"markdown-it-task-lists": "^2.1.1",
"marked": "^4.0.12", "marked": "^4.0.12",
"next": "12.0.10", "next": "12.0.10",
"prosemirror-markdown": "^1.7.0", "prosemirror-markdown": "^1.7.0",

View File

@ -12,7 +12,6 @@ import {
getProvider, getProvider,
destoryProvider, destoryProvider,
MenuBar, MenuBar,
Toc,
} from 'components/tiptap'; } from 'components/tiptap';
import { DataRender } from 'components/data-render'; import { DataRender } from 'components/data-render';
import { joinUser } from 'components/document/collaboration'; import { joinUser } from 'components/document/collaboration';

View File

@ -1,110 +0,0 @@
import { Document, TitledDocument, Title } from './extensions/title';
import Placeholder from '@tiptap/extension-placeholder';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Strike from '@tiptap/extension-strike';
import Underline from '@tiptap/extension-underline';
import TextStyle from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import Blockquote from '@tiptap/extension-blockquote';
import Bold from '@tiptap/extension-bold';
import Code from '@tiptap/extension-code';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import Dropcursor from '@tiptap/extension-dropcursor';
import Gapcursor from '@tiptap/extension-gapcursor';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import Italic from '@tiptap/extension-italic';
import OrderedList from '@tiptap/extension-ordered-list';
import BulletList from '@tiptap/extension-bullet-list';
import ListItem from '@tiptap/extension-list-item';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { HorizontalRule } from './extensions/horizontal-rule';
import { BackgroundColor } from './extensions/background-color';
import { Link } from './extensions/link';
import { FontSize } from './extensions/font-size';
import { ColorHighlighter } from './extensions/color-highlight';
import { Indent } from './extensions/indent';
import { Div } from './extensions/div';
import { Banner } from './extensions/banner';
import { CodeBlock } from './extensions/code-block';
import { Iframe } from './extensions/iframe';
import { Mind } from './extensions/mind';
import { Image } from './extensions/image';
import { Status } from './extensions/status';
import { Paste } from './extensions/paste';
import { Table, TableRow, TableCell, TableHeader } from './extensions/table';
import { Toc } from './extensions/toc';
import { TrailingNode } from './extensions/trailing-node';
import { Attachment } from './extensions/attachment';
import { Katex } from './extensions/katex';
import { DocumentReference } from './extensions/documents/reference';
import { DocumentChildren } from './extensions/documents/children';
export { Document, TitledDocument };
export const BaseExtension = [
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'title') {
return '请输入标题';
}
return '请输入内容';
},
showOnlyWhenEditable: true,
}),
Title,
Paragraph,
Text,
Strike,
Underline,
TextStyle,
Color,
BackgroundColor,
Bold,
Code,
Dropcursor,
Gapcursor,
HardBreak,
Heading,
HorizontalRule,
Italic,
OrderedList,
BulletList,
ListItem,
TaskList,
TaskItem.configure({
nested: true,
}),
Highlight.configure({ multicolor: true }),
TextAlign.configure({
types: ['heading', 'paragraph', 'image'],
}),
Link.configure({ openOnClick: false }),
Blockquote,
FontSize,
ColorHighlighter,
Indent,
CodeBlock,
Div,
Banner,
Iframe,
Mind,
Image,
Status,
Paste,
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader,
Toc,
TrailingNode,
Attachment,
Katex,
DocumentReference,
DocumentChildren,
];

View File

@ -0,0 +1,109 @@
import { Attachment } from './extensions/attachment';
import { BackgroundColor } from './extensions/backgroundColor';
import { Banner } from './extensions/banner';
import { Blockquote } from './extensions/blockquote';
import { Bold } from './extensions/bold';
import { BulletList } from './extensions/bulletList';
import { Code } from './extensions/code';
import { CodeBlock } from './extensions/codeBlock';
import { Color } from './extensions/color';
import { ColorHighlighter } from './extensions/colorHighlighter';
import { DocumentChildren } from './extensions/documentChildren';
import { DocumentReference } from './extensions/documentReference';
import { Dropcursor } from './extensions/dropCursor';
import { FontSize } from './extensions/fontSize';
import { FootnoteDefinition } from './extensions/footnoteDefinition';
import { FootnoteReference } from './extensions/footnoteReference';
import { FootnotesSection } from './extensions/footnotesSection';
import { Gapcursor } from './extensions/gapCursor';
import { HardBreak } from './extensions/hardBreak';
import { Heading } from './extensions/heading';
import { HorizontalRule } from './extensions/horizontalRule';
import { HTMLMarks } from './extensions/htmlMarks';
import { Iframe } from './extensions/iframe';
import { Image } from './extensions/image';
import { Italic } from './extensions/italic';
import { Katex } from './extensions/katex';
import { Link } from './extensions/link';
import { ListItem } from './extensions/listItem';
import { Mind } from './extensions/mind';
import { OrderedList } from './extensions/orderedList';
import { Paragraph } from './extensions/paragraph';
import { PasteFile } from './extensions/pasteFile';
import { PasteMarkdown } from './extensions/pasteMarkdown';
import { Placeholder } from './extensions/placeholder';
import { Status } from './extensions/status';
import { Strike } from './extensions/strike';
import { Table } from './extensions/table';
import { TableCell } from './extensions/tableCell';
import { TableHeader } from './extensions/tableHeader';
import { TableRow } from './extensions/tableRow';
import { Text } from './extensions/text';
import { TextAlign } from './extensions/textAlign';
import { TextStyle } from './extensions/textStyle';
import { TaskItem } from './extensions/taskItem';
import { TaskList } from './extensions/taskList';
import { Title } from './extensions/title';
import { TrailingNode } from './extensions/trailingNode';
import { Underline } from './extensions/underline';
export const BaseKit = [
Attachment,
BackgroundColor,
Banner,
Blockquote,
Bold,
BulletList,
Code,
CodeBlock,
Color,
ColorHighlighter,
DocumentChildren,
DocumentReference,
Dropcursor,
FontSize,
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
Gapcursor,
HardBreak,
Heading,
HorizontalRule,
...HTMLMarks,
Iframe,
Image,
Italic,
Katex,
Link,
ListItem,
Mind,
OrderedList,
Paragraph,
PasteFile,
PasteMarkdown,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'title') {
return '请输入标题';
}
return '请输入内容';
},
showOnlyWhenEditable: true,
}),
Status,
Strike,
Table,
TableCell,
TableHeader,
TableRow,
Text,
TextAlign.configure({
types: ['heading', 'paragraph', 'image'],
}),
TextStyle,
TaskItem,
TaskList,
Title,
TrailingNode,
Underline,
];

View File

@ -0,0 +1,28 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconDownload } from '@douyinfe/semi-icons';
import { download } from '../../services/download';
import styles from './index.module.scss';
export const AttachmentWrapper = ({ node }) => {
const { name, url } = node.attrs;
return (
<NodeViewWrapper as="div">
<div className={styles.wrap}>
<span>{name}</span>
<span>
<Tooltip zIndex={10000} content="下载">
<Button
theme={'borderless'}
type="tertiary"
icon={<IconDownload />}
onClick={() => download(url, name)}
/>
</Tooltip>
</span>
</div>
<NodeViewContent></NodeViewContent>
</NodeViewWrapper>
);
};

View File

@ -0,0 +1,16 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Banner as SemiBanner } from '@douyinfe/semi-ui';
import styles from './index.module.scss';
export const BannerWrapper = ({ node }) => {
return (
<NodeViewWrapper id="js-bannber-container" className={styles.wrap}>
<SemiBanner
type={node.attrs.type}
description={<NodeViewContent />}
closeIcon={null}
fullMode={false}
/>
</NodeViewWrapper>
);
};

View File

@ -8,7 +8,7 @@ import { lowlight } from 'lowlight';
import { copy } from 'helpers/copy'; import { copy } from 'helpers/copy';
import styles from './index.module.scss'; import styles from './index.module.scss';
const Render = ({ export const CodeBlockWrapper = ({
editor, editor,
node: { node: {
attrs: { language: defaultLanguage }, attrs: { language: defaultLanguage },
@ -53,9 +53,3 @@ const Render = ({
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const CodeBlock = CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
}).configure({ lowlight });

View File

@ -1,15 +1,7 @@
import { import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
Node,
Command,
mergeAttributes,
textInputRule,
textblockTypeInputRule,
wrappingInputRule,
} from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import { Space, Popover, Tag, Input, Typography } from '@douyinfe/semi-ui'; import { Typography } from '@douyinfe/semi-ui';
import { useChildrenDocument } from 'data/document'; import { useChildrenDocument } from 'data/document';
import { DataRender } from 'components/data-render'; import { DataRender } from 'components/data-render';
import { Empty } from 'components/empty'; import { Empty } from 'components/empty';
@ -18,70 +10,7 @@ import styles from './index.module.scss';
const { Text } = Typography; const { Text } = Typography;
declare module '@tiptap/core' { export const DocumentChildrenWrapper = () => {
interface Commands {
documentChildren: {
setDocumentChildren: () => Command;
};
}
}
export const DocumentChildrenInputRegex = /^documentChildren\$$/;
const DocumentChildrenExtension = Node.create({
name: 'documentChildren',
group: 'block',
defining: true,
draggable: true,
atom: true,
addAttributes() {
return {
color: {
default: 'grey',
},
text: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'div[data-type=documentChildren]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setDocumentChildren:
() =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {},
});
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: DocumentChildrenInputRegex,
type: this.type,
}),
];
},
});
const Render = () => {
const { pathname, query } = useRouter(); const { pathname, query } = useRouter();
const wikiId = query?.wikiId; const wikiId = query?.wikiId;
const documentId = query?.documentId; const documentId = query?.documentId;
@ -135,9 +64,3 @@ const Render = () => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const DocumentChildren = DocumentChildrenExtension.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -1,5 +1,4 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import { Select } from '@douyinfe/semi-ui'; import { Select } from '@douyinfe/semi-ui';
@ -8,73 +7,7 @@ import { DataRender } from 'components/data-render';
import { IconDocument } from 'components/icons'; import { IconDocument } from 'components/icons';
import styles from './index.module.scss'; import styles from './index.module.scss';
declare module '@tiptap/core' { export const DocumentReferenceWrapper = ({ editor, node, updateAttributes }) => {
interface Commands {
documentReference: {
setDocumentReference: () => Command;
};
}
}
export const DocumentReferenceInputRegex = /^documentReference\$$/;
const DocumentReferenceExtension = Node.create({
name: 'documentReference',
group: 'block',
defining: true,
draggable: true,
atom: true,
addAttributes() {
return {
wikiId: {
default: '',
},
documentId: {
default: '',
},
title: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'div[data-type=documentReference]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setDocumentReference:
() =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {},
});
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: DocumentReferenceInputRegex,
type: this.type,
}),
];
},
});
const Render = ({ editor, node, updateAttributes }) => {
const { pathname, query } = useRouter(); const { pathname, query } = useRouter();
const wikiIdFromUrl = query?.wikiId; const wikiIdFromUrl = query?.wikiId;
const isShare = pathname.includes('share'); const isShare = pathname.includes('share');
@ -83,7 +16,7 @@ const Render = ({ editor, node, updateAttributes }) => {
const { data: tocs, loading, error } = useWikiTocs(isShare ? null : wikiIdFromUrl); const { data: tocs, loading, error } = useWikiTocs(isShare ? null : wikiIdFromUrl);
const selectDoc = (str) => { const selectDoc = (str) => {
const [wikiId, documentId, title] = str.split('/'); const [wikiId, title, documentId] = str.split('/');
updateAttributes({ wikiId, documentId, title }); updateAttributes({ wikiId, documentId, title });
}; };
@ -98,12 +31,13 @@ const Render = ({ editor, node, updateAttributes }) => {
<Select <Select
placeholder="请选择文档" placeholder="请选择文档"
onChange={(v) => selectDoc(v)} onChange={(v) => selectDoc(v)}
{...(wikiId && documentId ? { value: `${wikiId}/${documentId}/${title}` } : {})} {...(wikiId && documentId ? { value: `${wikiId}/${title}/${documentId}` } : {})}
> >
{(tocs || []).map((toc) => ( {(tocs || []).map((toc) => (
<Select.Option <Select.Option
label={`${toc.id}/${toc.title}`} // FIXME: semi-design 抄 antd抄的什么玩意
value={`${toc.wikiId}/${toc.id}/${toc.title}`} label={`${toc.title}/${toc.id}`}
value={`${toc.wikiId}/${toc.title}/${toc.id}`}
> >
{toc.title} {toc.title}
</Select.Option> </Select.Option>
@ -129,9 +63,3 @@ const Render = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const DocumentReference = DocumentReferenceExtension.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -0,0 +1,47 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Input } from '@douyinfe/semi-ui';
import { Resizeable } from 'components/resizeable';
import styles from './index.module.scss';
export const IframeWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable;
const { url, width, height } = node.attrs;
const onResize = (size) => {
updateAttributes({ width: size.width, height: size.height });
};
const content = (
<NodeViewContent as="div" className={styles.wrap}>
{isEditable && (
<div className={styles.handlerWrap}>
<Input
placeholder={'输入外链地址'}
value={url}
onChange={(url) => updateAttributes({ url })}
></Input>
</div>
)}
{url && (
<div className={styles.innerWrap} style={{ pointerEvents: !isEditable ? 'auto' : 'none' }}>
<iframe src={url}></iframe>
</div>
)}
</NodeViewContent>
);
if (!isEditable && !url) {
return null;
}
return (
<NodeViewWrapper>
{isEditable ? (
<Resizeable height={height} width={width} onChange={onResize}>
{content}
</Resizeable>
) : (
<div style={{ width, height, maxWidth: '100%' }}>{content}</div>
)}
</NodeViewWrapper>
);
};

View File

@ -1,8 +1,7 @@
import { Image as TImage } from '@tiptap/extension-image'; import { NodeViewWrapper } from '@tiptap/react';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
const Render = ({ editor, node, updateAttributes }) => { export const ImageWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const { src, alt, title, width, height, textAlign } = node.attrs; const { src, alt, title, width, height, textAlign } = node.attrs;
@ -24,29 +23,3 @@ const Render = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const Image = TImage.extend({
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: 'auto',
},
height: {
default: 'auto',
},
};
},
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -1,77 +1,13 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; import { useMemo } from 'react';
import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui'; import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons'; import { IconHelpCircle } from '@douyinfe/semi-icons';
import katex from 'katex'; import katex from 'katex';
import styles from './index.module.scss'; import styles from './index.module.scss';
import { useMemo } from 'react';
declare module '@tiptap/core' {
interface Commands {
katex: {
setKatex: () => Command;
};
}
}
const { Text } = Typography; const { Text } = Typography;
export const KatexInputRegex = /^\$\$(.+)?\$$/; export const KatexWrapper = ({ editor, node, updateAttributes }) => {
const KatexExtension = Node.create({
name: 'katex',
group: 'block',
defining: true,
draggable: true,
atom: true,
addAttributes() {
return {
text: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'div[data-type=katex]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setKatex:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: KatexInputRegex,
type: this.type,
getAttributes: (match) => {
return { text: match[1] };
},
}),
];
},
});
const Render = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const { text } = node.attrs; const { text } = node.attrs;
const formatText = useMemo(() => { const formatText = useMemo(() => {
@ -123,9 +59,3 @@ const Render = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const Katex = KatexExtension.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@douyinfe/semi-ui'; import { Button } from '@douyinfe/semi-ui';
import { IconMinus, IconPlus } from '@douyinfe/semi-icons'; import { IconMinus, IconPlus } from '@douyinfe/semi-icons';
@ -9,84 +8,7 @@ import deepEqual from 'deep-equal';
import jsMind from './jsmind.jsx'; import jsMind from './jsmind.jsx';
import styles from './index.module.scss'; import styles from './index.module.scss';
const DEFAULT_MIND_DATA = { export const MindWrapper = ({ editor, node, updateAttributes }) => {
meta: {
name: 'jsMind',
author: 'think',
version: '0.2',
},
format: 'node_tree',
data: { id: 'root', topic: '中心节点', children: [] },
};
const MindNode = Node.create({
name: 'jsmind',
content: '',
marks: '',
group: 'block',
draggable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {
'data-type': 'jsmind',
},
};
},
addAttributes() {
return {
width: {
default: '100%',
},
height: {
default: 240,
},
data: {
default: DEFAULT_MIND_DATA,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="jsmind"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
// @ts-ignore
addCommands() {
return {
insertMind:
(options) =>
({ tr, commands, chain, editor }) => {
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
const { selection } = editor.state;
const pos = selection.$head;
return chain()
.insertContentAt(pos.before(), [
{
type: this.name,
attrs: { data: DEFAULT_MIND_DATA },
},
])
.run();
},
};
},
});
const Render = ({ editor, node, updateAttributes }) => {
const $container = useRef(); const $container = useRef();
const $mind = useRef<any>(); const $mind = useRef<any>();
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
@ -216,9 +138,3 @@ const Render = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const Mind = MindNode.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -2,7 +2,7 @@ import ReactDOM from 'react-dom';
import { Space, Button, Tooltip } from '@douyinfe/semi-ui'; import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons'; import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { IconZoomOut, IconZoomIn } from 'components/icons'; import { IconZoomOut, IconZoomIn } from 'components/icons';
import { Divider } from '../../components/divider'; import { Divider } from '../divider';
import styles from './index.module.scss'; import styles from './index.module.scss';
/* /*

View File

@ -1,61 +1,8 @@
import { Node, Command, mergeAttributes } from '@tiptap/core'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import { Space, Popover, Tag, Input } from '@douyinfe/semi-ui'; import { Space, Popover, Tag, Input } from '@douyinfe/semi-ui';
import styles from './index.module.scss'; import styles from './index.module.scss';
declare module '@tiptap/core' { export const StatusWrapper = ({ editor, node, updateAttributes }) => {
interface Commands {
status: {
setStatus: () => Command;
};
}
}
const StatusExtension = Node.create({
name: 'status',
content: 'text*',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
color: {
default: 'grey',
},
text: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'span[data-type=status]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setStatus:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
const Render = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const { color, text } = node.attrs; const { color, text } = node.attrs;
const content = <Tag color={color}>{text || '设置状态'}</Tag>; const content = <Tag color={color}>{text || '设置状态'}</Tag>;
@ -101,9 +48,3 @@ const Render = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const Status = StatusExtension.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -0,0 +1,6 @@
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
export const PARSE_HTML_PRIORITY_HIGHEST = 100;
export const EXTENSION_PRIORITY_LOWER = 75;
export const EXTENSION_PRIORITY_DEFAULT = 100;
export const EXTENSION_PRIORITY_HIGHEST = 200;

View File

@ -0,0 +1,50 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { AttachmentWrapper } from '../components/attachment';
export const Attachment = Node.create({
name: 'attachment',
group: 'block',
draggable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {
class: 'attachment',
},
};
},
parseHTML() {
return [{ tag: 'div[class=attachment]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addAttributes() {
return {
name: {
default: null,
},
url: {
default: null,
},
};
},
// @ts-ignore
addCommands() {
return {
setAttachment:
(attrs) =>
({ chain }) => {
return chain().insertContent({ type: this.name, attrs }).run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(AttachmentWrapper);
},
});

View File

@ -1,76 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconDownload } from '@douyinfe/semi-icons';
import { download } from '../../utils/download';
import styles from './index.module.scss';
const Render = ({ node }) => {
const { name, url } = node.attrs;
return (
<NodeViewWrapper as="div">
<div className={styles.wrap}>
<span>{name}</span>
<span>
<Tooltip zIndex={10000} content="下载">
<Button
theme={'borderless'}
type="tertiary"
icon={<IconDownload />}
onClick={() => download(url, name)}
/>
</Tooltip>
</span>
</div>
<NodeViewContent></NodeViewContent>
</NodeViewWrapper>
);
};
export const Attachment = Node.create({
name: 'attachment',
group: 'block',
draggable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {
class: 'attachment',
},
};
},
parseHTML() {
return [{ tag: 'div[class=attachment]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addAttributes() {
return {
name: {
default: null,
},
url: {
default: null,
},
};
},
// @ts-ignore
addCommands() {
return {
setAttachment:
(attrs) =>
({ chain }) => {
return chain().insertContent({ type: this.name, attrs }).run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -8,13 +8,7 @@ export type ColorOptions = {
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
backgroundColor: { backgroundColor: {
/**
* Set the text color
*/
setBackgroundColor: (color: string) => ReturnType; setBackgroundColor: (color: string) => ReturnType;
/**
* Unset the text color
*/
unsetBackgroundColor: () => ReturnType; unsetBackgroundColor: () => ReturnType;
}; };
} }

View File

@ -1,7 +1,6 @@
import { Node, Command, mergeAttributes } from '@tiptap/core'; import { Node, Command, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer } from '@tiptap/react';
import { Banner as SemiBanner } from '@douyinfe/semi-ui'; import { BannerWrapper } from '../components/banner';
import styles from './index.module.scss';
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands { interface Commands {
@ -11,7 +10,7 @@ declare module '@tiptap/core' {
} }
} }
const BannerExtension = Node.create({ export const Banner = Node.create({
name: 'banner', name: 'banner',
content: 'block*', content: 'block*',
group: 'block', group: 'block',
@ -57,23 +56,8 @@ const BannerExtension = Node.create({
}, },
}; };
}, },
});
const Render = ({ node }) => {
return (
<NodeViewWrapper id="js-bannber-container" className={styles.wrap}>
<SemiBanner
type={node.attrs.type}
description={<NodeViewContent />}
closeIcon={null}
fullMode={false}
/>
</NodeViewWrapper>
);
};
export const Banner = BannerExtension.extend({
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(Render); return ReactNodeViewRenderer(BannerWrapper);
}, },
}); });

View File

@ -0,0 +1,37 @@
import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote';
import { wrappingInputRule } from '@tiptap/core';
import { getParents } from '../services/dom';
import { getMarkdownSource } from '../services/markdownSourceMap';
export const Blockquote = BuiltInBlockquote.extend({
addAttributes() {
return {
...this.parent?.(),
multiline: {
default: false,
parseHTML: (element) => {
const source = getMarkdownSource(element);
const parentsIncludeBlockquote = getParents(element).some(
(p) => p.nodeName.toLowerCase() === 'blockquote'
);
return source && !source.startsWith('>') && !parentsIncludeBlockquote;
},
},
};
},
addInputRules() {
const multilineInputRegex = /^\s*>>>\s$/gm;
return [
...this.parent?.(),
wrappingInputRule({
find: multilineInputRegex,
type: this.type,
getAttributes: () => ({ multiline: true }),
}),
];
},
});

View File

@ -0,0 +1 @@
export { Bold } from '@tiptap/extension-bold';

View File

@ -0,0 +1,19 @@
import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list';
import { getMarkdownSource } from '../services/markdownSourceMap';
export const BulletList = BuiltInBulletList.extend({
addAttributes() {
return {
...this.parent?.(),
bullet: {
default: '*',
parseHTML(element) {
const bullet = getMarkdownSource(element)?.charAt(0);
return '*+-'.includes(bullet) ? bullet : '*';
},
},
};
},
});

View File

@ -0,0 +1,12 @@
import BuiltInCode from '@tiptap/extension-code';
import { EXTENSION_PRIORITY_LOWER } from '../constants';
export const Code = BuiltInCode.extend({
excludes: null,
/**
* Reduce the rendering priority of the code mark to
* ensure the bold, italic, and strikethrough marks
* are rendered first.
*/
priority: EXTENSION_PRIORITY_LOWER,
});

View File

@ -0,0 +1,37 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { lowlight } from 'lowlight/lib/all';
import { CodeBlockWrapper } from '../components/codeBlock';
const extractLanguage = (element) => element.getAttribute('lang');
export const CodeBlock = CodeBlockLowlight.extend({
isolating: true,
addAttributes() {
return {
language: {
default: null,
parseHTML: (element) => extractLanguage(element),
},
class: {
default: 'code highlight',
},
};
},
renderHTML({ HTMLAttributes }) {
return [
'pre',
{
...HTMLAttributes,
class: `content-editor-code-block ${HTMLAttributes.class}`,
},
['code', {}, 0],
];
},
addNodeView() {
return ReactNodeViewRenderer(CodeBlockWrapper);
},
}).configure({
lowlight,
});

View File

@ -0,0 +1,3 @@
import { Color } from '@tiptap/extension-color';
export { Color };

View File

@ -1,6 +1,6 @@
import { Extension } from '@tiptap/core'; import { Extension } from '@tiptap/core';
import { Plugin } from 'prosemirror-state'; import { Plugin } from 'prosemirror-state';
import findColors from '../utils/find-colors'; import { findColors } from '../services/color';
export const ColorHighlighter = Extension.create({ export const ColorHighlighter = Extension.create({
name: 'colorHighlighter', name: 'colorHighlighter',

View File

@ -1,39 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
export const Div = Node.create({
name: 'div',
addOptions() {
return {
HTMLAttributes: {},
};
},
content: 'block*',
group: 'block',
defining: true,
parseHTML() {
return [{ tag: 'div' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
// @ts-ignore
addCommands() {
return {
setDiv:
(attributes) =>
({ commands }) => {
return commands.wrapIn('div', attributes);
},
toggleDiv:
(attributes) =>
({ commands }) => {
return commands.toggleWrap('div', attributes);
},
unsetDiv:
(attributes) =>
({ commands }) => {
return commands.lift('div');
},
};
},
});

View File

@ -0,0 +1 @@
export { Document } from '@tiptap/extension-document';

View File

@ -0,0 +1,70 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { DocumentChildrenWrapper } from '../components/documentChildren';
declare module '@tiptap/core' {
interface Commands {
documentChildren: {
setDocumentChildren: () => Command;
};
}
}
export const DocumentChildrenInputRegex = /^documentChildren\$$/;
export const DocumentChildren = Node.create({
name: 'documentChildren',
group: 'block',
defining: true,
draggable: true,
atom: true,
addAttributes() {
return {
color: {
default: 'grey',
},
text: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'div[data-type=documentChildren]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setDocumentChildren:
() =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {},
});
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: DocumentChildrenInputRegex,
type: this.type,
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(DocumentChildrenWrapper);
},
});

View File

@ -0,0 +1,73 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { DocumentReferenceWrapper } from '../components/documentReference';
declare module '@tiptap/core' {
interface Commands {
documentReference: {
setDocumentReference: () => Command;
};
}
}
export const DocumentReferenceInputRegex = /^documentReference\$$/;
export const DocumentReference = Node.create({
name: 'documentReference',
group: 'block',
defining: true,
draggable: true,
atom: true,
addAttributes() {
return {
wikiId: {
default: '',
},
documentId: {
default: '',
},
title: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'div[data-type=documentReference]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setDocumentReference:
() =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {},
});
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: DocumentReferenceInputRegex,
type: this.type,
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(DocumentReferenceWrapper);
},
});

View File

@ -0,0 +1 @@
export { Dropcursor } from '@tiptap/extension-dropcursor';

View File

@ -0,0 +1,21 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export const FootnoteDefinition = Node.create({
name: 'footnoteDefinition',
content: 'paragraph',
group: 'block',
parseHTML() {
return [
{ tag: 'section.footnotes li' },
{ tag: '.footnote-backref', priority: PARSE_HTML_PRIORITY_HIGHEST, ignore: true },
];
},
renderHTML({ HTMLAttributes }) {
return ['li', mergeAttributes(HTMLAttributes), 0];
},
});

View File

@ -0,0 +1,37 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export const FootnoteReference = Node.create({
name: 'footnoteReference',
inline: true,
group: 'inline',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
footnoteId: {
default: null,
parseHTML: (element) => element.querySelector('a').getAttribute('id'),
},
footnoteNumber: {
default: null,
parseHTML: (element) => element.textContent,
},
};
},
parseHTML() {
return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }];
},
renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) {
return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber];
},
});

View File

@ -0,0 +1,19 @@
import { mergeAttributes, Node } from '@tiptap/core';
export const FootnotesSection = Node.create({
name: 'footnotesSection',
content: 'footnoteDefinition+',
group: 'block',
isolating: true,
parseHTML() {
return [{ tag: 'section.footnotes > ol' }];
},
renderHTML({ HTMLAttributes }) {
return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0];
},
});

View File

@ -0,0 +1 @@
export { Gapcursor } from '@tiptap/extension-gapcursor';

View File

@ -0,0 +1,3 @@
import HardBreak from '@tiptap/extension-hard-break';
export { HardBreak };

View File

@ -0,0 +1 @@
export { Heading } from '@tiptap/extension-heading';

View File

@ -8,9 +8,6 @@ export interface HorizontalRuleOptions {
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
horizontalRule: { horizontalRule: {
/**
* Add a horizontal rule
*/
setHorizontalRule: () => ReturnType; setHorizontalRule: () => ReturnType;
}; };
} }
@ -41,10 +38,8 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
setHorizontalRule: setHorizontalRule:
() => () =>
({ chain }) => { ({ chain }) => {
return ( return chain()
chain()
.insertContent({ type: this.name }) .insertContent({ type: this.name })
// set cursor after horizontal rule
.command(({ tr, dispatch }) => { .command(({ tr, dispatch }) => {
if (dispatch) { if (dispatch) {
const { $to } = tr.selection; const { $to } = tr.selection;
@ -53,7 +48,6 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
if ($to.nodeAfter) { if ($to.nodeAfter) {
tr.setSelection(TextSelection.create(tr.doc, $to.pos)); tr.setSelection(TextSelection.create(tr.doc, $to.pos));
} else { } else {
// add node after horizontal rule if its the end of the document
const node = $to.parent.type.contentMatch.defaultType?.create(); const node = $to.parent.type.contentMatch.defaultType?.create();
if (node) { if (node) {
@ -67,8 +61,7 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
return true; return true;
}) })
.run() .run();
);
}, },
}; };
}, },

View File

@ -0,0 +1,71 @@
import { Mark, mergeAttributes, markInputRule } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
import { markInputRegex, extractMarkAttributesFromMatch } from '../services/markUtils';
const marks = [
'ins',
'abbr',
'bdo',
'cite',
'dfn',
'mark',
'small',
'span',
'time',
'kbd',
'q',
'samp',
'var',
'ruby',
'rp',
'rt',
];
const attrs = {
time: ['datetime'],
abbr: ['title'],
span: ['dir'],
bdo: ['dir'],
};
export const HTMLMarks = marks.map((name) =>
Mark.create({
name,
inclusive: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return (attrs[name] || []).reduce(
(acc, attr) => ({
...acc,
[attr]: {
default: null,
parseHTML: (element) => element.getAttribute(attr),
},
}),
{}
);
},
parseHTML() {
return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }];
},
renderHTML({ HTMLAttributes }) {
return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addInputRules() {
return [
markInputRule({
find: markInputRegex(name),
type: this.type,
getAttributes: extractMarkAttributesFromMatch,
}),
];
},
})
);

View File

@ -0,0 +1,76 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { IframeWrapper } from '../components/iframe';
export const Iframe = Node.create({
name: 'external-iframe',
content: '',
marks: '',
group: 'block',
draggable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {
'data-type': 'external-iframe',
},
};
},
addAttributes() {
return {
width: {
default: '100%',
},
height: {
default: 54,
},
url: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'iframe[data-type="external-iframe"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['iframe', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
// @ts-ignore
addCommands() {
return {
insertIframe:
(options) =>
({ tr, commands, chain, editor }) => {
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
const { url } = options || {};
const { selection } = editor.state;
const pos = selection.$head;
return chain()
.insertContentAt(pos.before(), [
{
type: this.name,
attrs: { url },
},
])
.run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(IframeWrapper);
},
});

View File

@ -1,123 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import { Input } from '@douyinfe/semi-ui';
import { Resizeable } from 'components/resizeable';
import styles from './index.module.scss';
const IframeNode = Node.create({
name: 'external-iframe',
content: '',
marks: '',
group: 'block',
draggable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {
'data-type': 'external-iframe',
},
};
},
addAttributes() {
return {
width: {
default: '100%',
},
height: {
default: 54,
},
url: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'iframe[data-type="external-iframe"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['iframe', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
// @ts-ignore
addCommands() {
return {
insertIframe:
(options) =>
({ tr, commands, chain, editor }) => {
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
const { url } = options || {};
const { selection } = editor.state;
const pos = selection.$head;
return chain()
.insertContentAt(pos.before(), [
{
type: this.name,
attrs: { url },
},
])
.run();
},
};
},
});
const Render = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable;
const { url, width, height } = node.attrs;
const onResize = (size) => {
updateAttributes({ width: size.width, height: size.height });
};
const content = (
<NodeViewContent as="div" className={styles.wrap}>
{isEditable && (
<div className={styles.handlerWrap}>
<Input
placeholder={'输入外链地址'}
value={url}
onChange={(url) => updateAttributes({ url })}
></Input>
</div>
)}
{url && (
<div className={styles.innerWrap} style={{ pointerEvents: !isEditable ? 'auto' : 'none' }}>
<iframe src={url}></iframe>
</div>
)}
</NodeViewContent>
);
if (!isEditable && !url) {
return null;
}
return (
<NodeViewWrapper>
{isEditable ? (
<Resizeable height={height} width={width} onChange={onResize}>
{content}
</Resizeable>
) : (
<div style={{ width, height, maxWidth: '100%' }}>{content}</div>
)}
</NodeViewWrapper>
);
};
export const Iframe = IframeNode.extend({
addNodeView() {
return ReactNodeViewRenderer(Render);
},
});

View File

@ -0,0 +1,48 @@
import { Image as BuiltInImage } from '@tiptap/extension-image';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { ImageWrapper } from '../components/image';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
export const Image = BuiltInImage.extend({
addOptions() {
return {
...this.parent?.(),
inline: true,
};
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
parseHTML: (element) => {
const img = resolveImageEl(element);
return img.dataset.src || img.getAttribute('src');
},
},
alt: {
default: null,
parseHTML: (element) => {
const img = resolveImageEl(element);
return img.getAttribute('alt');
},
},
title: {
default: null,
},
width: {
default: 'auto',
},
height: {
default: 'auto',
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageWrapper);
},
});

View File

@ -1,164 +0,0 @@
import { Command, Extension } from '@tiptap/core';
import { sinkListItem, liftListItem } from 'prosemirror-schema-list';
import { TextSelection, AllSelection, Transaction } from 'prosemirror-state';
import { clamp } from '../utils/shared';
import { isListActive } from '../utils/active';
import { getNodeType } from '../utils/type';
import { isListNode } from '../utils/node';
type IndentOptions = {
types: string[];
indentLevels: number[];
defaultIndentLevel: number;
};
declare module '@tiptap/core' {
interface Commands {
indent: {
indent: () => Command;
outdent: () => Command;
};
}
}
export enum IndentProps {
min = 0,
max = 210,
more = 30,
less = -30,
}
function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number): Transaction {
if (!tr.doc) return tr;
const node = tr.doc.nodeAt(pos);
if (!node) return tr;
const minIndent = IndentProps.min;
const maxIndent = IndentProps.max;
const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent);
if (indent === node.attrs.indent) return tr;
const nodeAttrs = {
...node.attrs,
indent,
};
return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks);
}
function updateIndentLevel(tr: Transaction, delta: number): Transaction {
const { doc, selection } = tr;
if (!doc || !selection) return tr;
if (!(selection instanceof TextSelection || selection instanceof AllSelection)) {
return tr;
}
const { from, to } = selection;
doc.nodesBetween(from, to, (node, pos) => {
const nodeType = node.type;
if (nodeType.name === 'paragraph' || nodeType.name === 'heading') {
tr = setNodeIndentMarkup(tr, pos, delta);
return false;
}
if (isListNode(node)) {
return false;
}
return true;
});
return tr;
}
export const Indent = Extension.create<IndentOptions>({
name: 'indent',
addOptions() {
return {
types: ['heading', 'paragraph'],
indentLevels: [0, 30, 60, 90, 120, 150, 180, 210],
defaultIndentLevel: 0,
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
indent: {
default: this.options.defaultIndentLevel,
renderHTML: (attributes) => ({
style: `margin-left: ${attributes.indent}px!important;`,
}),
parseHTML: (element) =>
parseInt(element.style.marginLeft) || this.options.defaultIndentLevel,
},
},
},
];
},
addCommands() {
return {
indent:
() =>
({ tr, state, dispatch }) => {
if (isListActive(this.editor)) {
const name = this.editor.can().liftListItem('taskItem') ? 'taskItem' : 'listItem';
const type = getNodeType(name, state.schema);
return sinkListItem(type)(state, dispatch);
}
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndentLevel(tr, IndentProps.more);
if (tr.docChanged) {
dispatch && dispatch(tr);
return true;
}
return false;
},
outdent:
() =>
({ tr, state, dispatch }) => {
if (isListActive(this.editor)) {
const name = this.editor.can().liftListItem('taskItem') ? 'taskItem' : 'listItem';
const type = getNodeType(name, state.schema);
return liftListItem(type)(state, dispatch);
}
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndentLevel(tr, IndentProps.less);
if (tr.docChanged) {
dispatch && dispatch(tr);
return true;
}
return false;
},
};
},
// @ts-ignore
addKeyboardShortcuts() {
return {
'Tab': () => {
return this.editor.commands.indent();
},
'Shift-Tab': () => {
return this.editor.commands.outdent();
},
};
},
});

View File

@ -0,0 +1 @@
export { Italic } from '@tiptap/extension-italic';

View File

@ -0,0 +1,71 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { KatexWrapper } from '../components/katex';
declare module '@tiptap/core' {
interface Commands {
katex: {
setKatex: () => Command;
};
}
}
export const KatexInputRegex = /^\$\$(.+)?\$\$$/;
export const Katex = Node.create({
name: 'katex',
group: 'block',
defining: true,
draggable: true,
selectable: true,
atom: true,
addAttributes() {
return {
text: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'div[data-type=katex]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setKatex:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: KatexInputRegex,
type: this.type,
getAttributes: (match) => {
return { text: match[1] };
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(KatexWrapper);
},
});

View File

@ -0,0 +1,62 @@
import { markInputRule } from '@tiptap/core';
import { Link as BuiltInLink } from '@tiptap/extension-link';
const extractHrefFromMatch = (match) => {
return { href: match.groups.href };
};
export const extractHrefFromMarkdownLink = (match) => {
/**
* Removes the last capture group from the match to satisfy
* tiptap markInputRule expectation of having the content as
* the last capture group in the match.
*
* https://github.com/ueberdosis/tiptap/blob/%40tiptap/core%402.0.0-beta.75/packages/core/src/inputRules/markInputRule.ts#L11
*/
match.pop();
return extractHrefFromMatch(match);
};
export const Link = BuiltInLink.extend({
addOptions() {
return {
...this.parent?.(),
openOnClick: false,
};
},
addInputRules() {
const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
return [
markInputRule({
find: markdownLinkSyntaxInputRuleRegExp,
type: this.type,
getAttributes: extractHrefFromMarkdownLink,
}),
markInputRule({
find: urlSyntaxRegExp,
type: this.type,
getAttributes: extractHrefFromMatch,
}),
];
},
addAttributes() {
return {
...this.parent?.(),
href: {
default: null,
parseHTML: (element) => element.getAttribute('href'),
},
title: {
title: null,
parseHTML: (element) => element.getAttribute('title'),
},
canonicalSrc: {
default: null,
parseHTML: (element) => element.dataset.canonicalSrc,
},
};
},
});

View File

@ -1,3 +0,0 @@
import { Link as TLink } from '@tiptap/extension-link';
export const Link = TLink.extend({});

View File

@ -0,0 +1 @@
export { ListItem } from '@tiptap/extension-list-item';

View File

@ -0,0 +1,84 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { MindWrapper } from '../components/mind';
const DEFAULT_MIND_DATA = {
meta: {
name: 'jsMind',
author: 'think',
version: '0.2',
},
format: 'node_tree',
data: { id: 'root', topic: '中心节点', children: [] },
};
export const Mind = Node.create({
name: 'jsmind',
content: '',
marks: '',
group: 'block',
draggable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {
'data-type': 'jsmind',
},
};
},
addAttributes() {
return {
width: {
default: '100%',
},
height: {
default: 240,
},
data: {
default: DEFAULT_MIND_DATA,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="jsmind"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
// @ts-ignore
addCommands() {
return {
insertMind:
(options) =>
({ tr, commands, chain, editor }) => {
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
const { selection } = editor.state;
const pos = selection.$head;
return chain()
.insertContentAt(pos.before(), [
{
type: this.name,
attrs: { data: DEFAULT_MIND_DATA },
},
])
.run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(MindWrapper);
},
});

View File

@ -0,0 +1,15 @@
import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list';
import { getMarkdownSource } from '../services/markdownSourceMap';
export const OrderedList = BuiltInOrderedList.extend({
addAttributes() {
return {
...this.parent?.(),
parens: {
default: false,
parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
},
};
},
});

View File

@ -0,0 +1 @@
export { Paragraph } from '@tiptap/extension-paragraph';

View File

@ -1,198 +0,0 @@
import { Plugin, EditorState } from 'prosemirror-state';
import { Extension } from '@tiptap/core';
// @ts-ignore
import { lowlight } from 'lowlight';
import { uploadFile } from 'services/file';
import { Attachment } from './attachment';
import { Image } from './image';
import { markdownSerializer } from '../markdown';
const isMarkActive =
(type) =>
(state: EditorState): boolean => {
if (!type) {
return false;
}
const { from, $from, to, empty } = state.selection;
return empty
? type.isInSet(state.storedMarks || $from.marks())
: state.doc.rangeHasMark(from, to, type);
};
export default function isInCode(state: EditorState): boolean {
if (state.schema.nodes.codeBlock) {
const $head = state.selection.$head;
for (let d = $head.depth; d > 0; d--) {
if ($head.node(d).type === state.schema.nodes.codeBlock) {
return true;
}
}
}
return isMarkActive(state.schema.marks.code)(state);
}
const LANGUAGES = lowlight.listLanguages().reduce((a, language) => {
a[language] = language;
return a;
}, {});
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
};
function isMarkdown(text: string): boolean {
// code-ish
const fences = text.match(/^```/gm);
if (fences && fences.length > 1) return true;
// link-ish
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
// heading-ish
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
// list-ish
const listItems = text.match(/^[\d-*].?\s\S+/gm);
if (listItems && listItems.length > 1) return true;
return false;
}
function normalizePastedMarkdown(text: string): string {
const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim;
while (text.match(CHECKBOX_REGEX)) {
text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`);
}
return text;
}
export const Paste = Extension.create({
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// @ts-ignore
handlePaste: async (view, event: ClipboardEvent) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.clipboardData) return false;
const file = event.clipboardData.files[0];
const text = event.clipboardData.getData('text/plain');
const html = event.clipboardData.getData('text/html');
const vscode = event.clipboardData.getData('vscode-editor-data');
if (file) {
event.preventDefault();
const url = await uploadFile(file);
let node = null;
if (acceptedMimes.image.includes(file?.type)) {
node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
} else {
node = view.props.state.schema.nodes[Attachment.name].create({
url,
name: file.name,
});
}
const transaction = view.state.tr.replaceSelectionWith(node);
view.dispatch(transaction);
return true;
}
// 粘贴代码
if (isInCode(view.state)) {
event.preventDefault();
view.dispatch(view.state.tr.insertText(text));
return true;
}
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
const pasteCodeLanguage = vscodeMeta?.mode;
// if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
// event.preventDefault();
// view.dispatch(
// view.state.tr.replaceSelectionWith(
// view.state.schema.nodes.codeBlock.create({
// language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
// ? vscodeMeta.mode
// : null,
// })
// )
// );
// view.dispatch(view.state.tr.insertText(text));
// return true;
// }
// 处理 markdown
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
event.preventDefault();
const paste = markdownSerializer.deserialize({
schema: view.props.state.schema,
content: normalizePastedMarkdown(text),
});
const transaction = view.state.tr.replaceSelectionWith(paste);
view.dispatch(transaction);
return true;
}
return false;
},
// @ts-ignore
handleDrop: async (view, event: any) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
const hasFiles = event.dataTransfer.files.length > 0;
if (!hasFiles) return false;
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
files.forEach(async (file: any) => {
if (!file) {
return;
}
const url = await uploadFile(file);
let node = null;
if (acceptedMimes.image.includes(file?.type)) {
node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
} else {
node = view.props.state.schema.nodes[Attachment.name].create({
url,
name: file.name,
});
}
const transaction = view.state.tr.replaceSelectionWith(node);
view.dispatch(transaction);
return true;
});
},
clipboardTextSerializer: (slice) => {
const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content);
if (!doc) {
return '';
}
const content = markdownSerializer.serialize({
schema: this.editor.schema,
document: doc,
});
return content;
},
},
}),
];
},
});

View File

@ -0,0 +1,83 @@
import { Plugin } from 'prosemirror-state';
import { Extension } from '@tiptap/core';
import { uploadFile } from 'services/file';
import { Attachment } from './attachment';
import { Image } from './image';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
};
export const PasteFile = Extension.create({
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// @ts-ignore
handlePaste: async (view, event: ClipboardEvent) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.clipboardData) return false;
const file = event.clipboardData.files[0];
if (file) {
event.preventDefault();
const url = await uploadFile(file);
let node = null;
if (acceptedMimes.image.includes(file?.type)) {
node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
} else {
node = view.props.state.schema.nodes[Attachment.name].create({
url,
name: file.name,
});
}
const transaction = view.state.tr.replaceSelectionWith(node);
view.dispatch(transaction);
return true;
}
return false;
},
// @ts-ignore
handleDrop: async (view, event: any) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
const hasFiles = event.dataTransfer.files.length > 0;
if (!hasFiles) return false;
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
files.forEach(async (file: any) => {
if (!file) {
return;
}
const url = await uploadFile(file);
let node = null;
if (acceptedMimes.image.includes(file?.type)) {
node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
} else {
node = view.props.state.schema.nodes[Attachment.name].create({
url,
name: file.name,
});
}
const transaction = view.state.tr.replaceSelectionWith(node);
view.dispatch(transaction);
return true;
});
},
},
}),
];
},
});

View File

@ -0,0 +1,71 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import { markdownSerializer } from '../services/serializer';
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
const TEXT_FORMAT = 'text/plain';
const HTML_FORMAT = 'text/html';
const VS_CODE_FORMAT = 'vscode-editor-data';
export const PasteMarkdown = Extension.create({
name: 'pasteMarkdown',
priority: EXTENSION_PRIORITY_HIGHEST,
// @ts-ignore
addCommands() {
return {
pasteMarkdown: (markdown) => () => {
const { editor } = this;
const { state, view } = editor;
const { tr, selection } = state;
const document = markdownSerializer.deserialize({
schema: view.props.state.schema,
content: markdown,
});
// tr.replaceWith(selection.from - 1, selection.to, document.content);
// view.dispatch(tr);
const transaction = view.state.tr.replaceSelectionWith(document);
view.dispatch(transaction);
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
handlePaste: (_, event) => {
const { clipboardData } = event;
const content = clipboardData.getData(TEXT_FORMAT);
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
const language = vsCodeMeta.mode;
if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
return false;
}
// @ts-ignore
this.editor.commands.pasteMarkdown(content);
return true;
},
clipboardTextSerializer: (slice) => {
const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content);
if (!doc) {
return '';
}
const content = markdownSerializer.serialize({
schema: this.editor.schema,
content: doc,
});
return content;
},
},
}),
];
},
});

View File

@ -0,0 +1,3 @@
import Placeholder from '@tiptap/extension-placeholder';
export { Placeholder };

View File

@ -0,0 +1,59 @@
import { Node, Command, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { StatusWrapper } from '../components/status';
declare module '@tiptap/core' {
interface Commands {
status: {
setStatus: () => Command;
};
}
}
export const Status = Node.create({
name: 'status',
content: 'text*',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
color: {
default: 'grey',
},
text: {
default: '',
},
};
},
parseHTML() {
return [{ tag: 'span[data-type=status]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
},
// @ts-ignore
addCommands() {
return {
setStatus:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(StatusWrapper);
},
});

View File

@ -0,0 +1 @@
export { Strike } from '@tiptap/extension-strike';

View File

@ -0,0 +1,5 @@
import { Table as BuiltInTable } from '@tiptap/extension-table';
export const Table = BuiltInTable.configure({
resizable: true,
});

View File

@ -1,6 +0,0 @@
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
export { Table, TableRow, TableCell, TableHeader };

View File

@ -0,0 +1 @@
export { TableCell } from '@tiptap/extension-table-cell';

View File

@ -0,0 +1 @@
export { TableHeader } from '@tiptap/extension-table-header';

View File

@ -0,0 +1,5 @@
import { TableRow as BuiltInTableRow } from '@tiptap/extension-table-row';
export const TableRow = BuiltInTableRow.extend({
allowGapCursor: false,
});

View File

@ -0,0 +1,37 @@
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export const TaskItem = BuiltInTaskItem.extend({
addOptions() {
return {
nested: true,
HTMLAttributes: {},
};
},
addAttributes() {
return {
checked: {
default: false,
parseHTML: (element) => {
const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox');
// @ts-ignore
return checkbox?.checked;
},
renderHTML: (attributes) => ({
'data-checked': attributes.checked,
}),
keepOnSplit: false,
},
};
},
parseHTML() {
return [
{
tag: 'li.task-list-item',
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
});

View File

@ -0,0 +1,38 @@
import { mergeAttributes } from '@tiptap/core';
import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import { getMarkdownSource } from '../services/markdownSourceMap';
export const TaskList = BuiltInTaskList.extend({
addAttributes() {
return {
numeric: {
default: false,
parseHTML: (element) => element.tagName.toLowerCase() === 'ol',
},
start: {
default: 1,
parseHTML: (element) =>
element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1,
},
parens: {
default: false,
parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
},
};
},
parseHTML() {
return [
{
tag: '.task-list',
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
},
});

View File

@ -0,0 +1 @@
export { Text } from '@tiptap/extension-text';

View File

@ -0,0 +1,3 @@
import TextAlign from '@tiptap/extension-text-align';
export { TextAlign };

View File

@ -0,0 +1,3 @@
import TextStyle from '@tiptap/extension-text-style';
export { TextStyle };

View File

@ -1,7 +1,6 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes } from '@tiptap/core';
import Document from '@tiptap/extension-document';
const Title = Node.create({ export const Title = Node.create({
name: 'title', name: 'title',
group: 'block', group: 'block',
content: 'text*', content: 'text*',
@ -26,9 +25,3 @@ const Title = Node.create({
return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
}, },
}); });
const TitledDocument = Document.extend({
content: 'title block+',
});
export { Document, Title, TitledDocument };

View File

@ -1,107 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Command, Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
declare module '@tiptap/core' {
interface Commands {
tableOfContents: {
setToc: () => Command;
};
}
}
const Component = ({ editor }) => {
const [items, setItems] = useState([]);
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, []);
useEffect(() => {
if (!editor) {
return null;
}
editor.on('update', handleUpdate);
return () => {
editor.off('update', handleUpdate);
};
}, [editor]);
return null;
};
export const Toc = Node.create({
name: 'tableOfContents',
group: 'block',
atom: true,
parseHTML() {
return [
{
tag: 'toc',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['toc', mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(Component);
},
addGlobalAttributes() {
return [
{
types: ['heading'],
attributes: {
id: {
default: null,
},
},
},
];
},
addCommands() {
return {
setToc:
() =>
({ chain }) => {
return chain().insertContent({ type: this.name }).run();
},
};
},
});

View File

@ -0,0 +1,3 @@
import Underline from '@tiptap/extension-underline';
export { Underline };

View File

@ -1,17 +1,22 @@
import { BaseExtension, Document, TitledDocument as DocumentWithTitle } from './base-kit';
import { HocuspocusProvider } from '@hocuspocus/provider'; import { HocuspocusProvider } from '@hocuspocus/provider';
import Collaboration from '@tiptap/extension-collaboration'; import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import { getRandomColor } from 'helpers/color'; import { getRandomColor } from 'helpers/color';
import { Document } from './extensions/document';
import { BaseKit } from './basekit';
export { getSchema } from '@tiptap/core'; export { getSchema } from '@tiptap/core';
export * from './menubar'; export * from './menubar';
export * from './provider'; export * from './provider';
export * from './skeleton'; export * from './skeleton';
export * from './toc';
export { Document, DocumentWithTitle }; export const DocumentWithTitle = Document.extend({
export const DEFAULT_EXTENSION = [...BaseExtension]; content: 'title block+',
});
export { Document };
export const DEFAULT_EXTENSION = [...BaseKit];
export const getCollaborationExtension = (provider: HocuspocusProvider) => { export const getCollaborationExtension = (provider: HocuspocusProvider) => {
return Collaboration.configure({ return Collaboration.configure({
document: provider.document, document: provider.document,

View File

@ -1 +0,0 @@
export * from './serializer';

View File

@ -1,246 +0,0 @@
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import { marked } from 'marked';
import { sanitize } from 'dompurify';
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
} from 'prosemirror-markdown';
import { Document, TitledDocument, Title } from '../extensions/title';
import Placeholder from '@tiptap/extension-placeholder';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Strike from '@tiptap/extension-strike';
import Underline from '@tiptap/extension-underline';
import TextStyle from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import Blockquote from '@tiptap/extension-blockquote';
import Bold from '@tiptap/extension-bold';
import Code from '@tiptap/extension-code';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import Dropcursor from '@tiptap/extension-dropcursor';
import Gapcursor from '@tiptap/extension-gapcursor';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import Italic from '@tiptap/extension-italic';
import OrderedList from '@tiptap/extension-ordered-list';
import BulletList from '@tiptap/extension-bullet-list';
import ListItem from '@tiptap/extension-list-item';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { BackgroundColor } from '../extensions/background-color';
import { Link } from '../extensions/link';
import { FontSize } from '../extensions/font-size';
import { ColorHighlighter } from '../extensions/color-highlight';
import { Indent } from '../extensions/indent';
import { Div } from '../extensions/div';
import { Banner } from '../extensions/banner';
import { CodeBlock } from '../extensions/code-block';
import { Iframe } from '../extensions/iframe';
import { Mind } from '../extensions/mind';
import { Katex } from '../extensions/katex';
import { Image } from '../extensions/image';
import { HorizontalRule } from '../extensions/horizontal-rule';
import { Table, TableCell, TableHeader, TableRow } from '../extensions/table';
import { DocumentChildren } from '../extensions/documents/children';
import {
isPlainURL,
renderHardBreak,
renderTable,
renderTableCell,
renderTableRow,
openTag,
closeTag,
renderOrderedList,
renderImage,
renderPlayable,
renderHTMLNode,
renderContent,
} from './helpers';
const defaultSerializerConfig = {
marks: {
[Bold.name]: defaultMarkdownSerializer.marks.strong,
[Italic.name]: {
open: '_',
close: '_',
mixable: true,
expelEnclosingWhitespace: true,
},
[Code.name]: defaultMarkdownSerializer.marks.code,
// [Subscript.name]: { open: "<sub>", close: "</sub>", mixable: true },
// [Superscript.name]: { open: "<sup>", close: "</sup>", mixable: true },
// [InlineDiff.name]: {
// mixable: true,
// open(state, mark) {
// return mark.attrs.type === "addition" ? "{+" : "{-";
// },
// close(state, mark) {
// return mark.attrs.type === "addition" ? "+}" : "-}";
// },
// },
[Link.name]: {
open(state, mark, parent, index) {
return isPlainURL(mark, parent, index, 1) ? '<' : '[';
},
close(state, mark, parent, index) {
const href = mark.attrs.canonicalSrc || mark.attrs.href;
return isPlainURL(mark, parent, index, -1)
? '>'
: `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
[Strike.name]: {
open: '~~',
close: '~~',
mixable: true,
expelEnclosingWhitespace: true,
},
},
nodes: {
// [Audio.name]: renderPlayable,
[Blockquote.name]: (state, node) => {
if (node.attrs.multiline) {
state.write('>>>');
state.ensureNewLine();
state.renderContent(node);
state.ensureNewLine();
state.write('>>>');
state.closeBlock(node);
} else {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
}
},
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlock.name]: (state, node) => {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write('```');
state.closeBlock(node);
},
[Katex.name]: (state, node) => {
state.ensureNewLine();
state.write(`\$\$${node.attrs.text || ''}\$`);
state.closeBlock(node);
},
[DocumentChildren.name]: (state, node) => {
state.ensureNewLine();
state.write(`documentChildren$`);
state.closeBlock(node);
},
[Mind.name]: (state, node) => {
state.ensureNewLine();
state.write(`mind$`);
state.closeBlock(node);
},
// [DescriptionList.name]: renderHTMLNode("dl", true),
// [DescriptionItem.name]: (state, node, parent, index) => {
// if (index === 1) state.ensureNewLine();
// renderHTMLNode(node.attrs.isTerm ? "dt" : "dd")(state, node);
// if (index === parent.childCount - 1) state.ensureNewLine();
// },
// [Details.name]: renderHTMLNode("details", true),
// [DetailsContent.name]: (state, node, parent, index) => {
// if (!index) renderHTMLNode("summary")(state, node);
// else {
// if (index === 1) state.ensureNewLine();
// renderContent(state, node);
// if (index === parent.childCount - 1) state.ensureNewLine();
// }
// },
// [Emoji.name]: (state, node) => {
// const { name } = node.attrs;
// state.write(`:${name}:`);
// },
// [FootnoteDefinition.name]: (state, node) => {
// state.renderInline(node);
// },
// [FootnoteReference.name]: (state, node) => {
// state.write(`[^${node.attrs.footnoteNumber}]`);
// },
// [FootnotesSection.name]: (state, node) => {
// state.renderList(node, "", (index) => `[^${index + 1}]: `);
// },
// [Frontmatter.name]: (state, node) => {
// const { language } = node.attrs;
// const syntax = {
// toml: "+++",
// json: ";;;",
// yaml: "---",
// }[language];
// state.write(`${syntax}\n`);
// state.text(node.textContent, false);
// state.ensureNewLine();
// state.write(syntax);
// state.closeBlock(node);
// },
[Title.name]: renderHTMLNode('div', true, true),
// [FigureCaption.name]: renderHTMLNode("figcaption"),
[HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
[Image.name]: renderImage,
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
[OrderedList.name]: renderOrderedList,
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
// [Reference.name]: (state, node) => {
// state.write(node.attrs.originalText || node.attrs.text);
// },
// [TableOfContents.name]: (state, node) => {
// state.write("[[_TOC_]]");
// state.closeBlock(node);
// },
[Table.name]: renderTable,
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
[TableRow.name]: renderTableRow,
[TaskItem.name]: (state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
},
[TaskList.name]: (state, node) => {
if (node.attrs.numeric) renderOrderedList(state, node);
else defaultMarkdownSerializer.nodes.bullet_list(state, node);
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
},
};
const renderMarkdown = (rawMarkdown) => {
return sanitize(marked(rawMarkdown), {});
};
const createMarkdownSerializer = () => ({
deserialize: ({ schema, content }) => {
const html = renderMarkdown(content);
if (!html) return null;
const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
body.append(document.createComment(content));
const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
return state;
},
serialize: ({ schema, document }) => {
// const proseMirrorDocument = schema.nodeFromJSON(content);
const serializer = new ProseMirrorMarkdownSerializer(
{
...defaultSerializerConfig.nodes,
},
{
...defaultSerializerConfig.marks,
}
);
return serializer.serialize(document, {
tightLists: true,
});
},
});
export const markdownSerializer = createMarkdownSerializer();

View File

@ -6,7 +6,7 @@ import {
IconAlignRight, IconAlignRight,
IconAlignJustify, IconAlignJustify,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { isTitleActive } from '../utils/active'; import { isTitleActive } from './utils/active';
export const AlignMenu = ({ editor }) => { export const AlignMenu = ({ editor }) => {
const current = (() => { const current = (() => {

View File

@ -6,10 +6,10 @@ import {
IconClear, IconClear,
IconInfoCircle, IconInfoCircle,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { BubbleMenu } from '../components/bubble-menu'; import { BubbleMenu } from './components/bubble-menu';
import { Divider } from '../components/divider'; import { Divider } from '../components/divider';
import { Banner } from '../extensions/banner'; import { Banner } from '../extensions/banner';
import { deleteNode } from '../utils/delete'; import { deleteNode } from './utils/delete';
export const BannerBubbleMenu = ({ editor }) => { export const BannerBubbleMenu = ({ editor }) => {
return ( return (

View File

@ -10,8 +10,8 @@ import { Iframe } from '../extensions/iframe';
import { Mind } from '../extensions/mind'; import { Mind } from '../extensions/mind';
import { Table } from '../extensions/table'; import { Table } from '../extensions/table';
import { Katex } from '../extensions/katex'; import { Katex } from '../extensions/katex';
import { DocumentReference } from '../extensions/documents/reference'; import { DocumentReference } from '../extensions/documentReference';
import { DocumentChildren } from '../extensions/documents/children'; import { DocumentChildren } from '../extensions/documentChildren';
import { BaseMenu } from './base-menu'; import { BaseMenu } from './base-menu';
const OTHER_BUBBLE_MENU_TYPES = [ const OTHER_BUBBLE_MENU_TYPES = [

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui'; import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons'; import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons';
import { isTitleActive } from '../utils/active'; import { isTitleActive } from './utils/active';
import { Emoji } from './components/emoji'; import { Emoji } from './components/emoji';
export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => { export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {

View File

@ -7,7 +7,7 @@ import {
IconUnderline, IconUnderline,
IconCode, IconCode,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { isTitleActive } from '../utils/active'; import { isTitleActive } from './utils/active';
import { ColorMenu } from './color'; import { ColorMenu } from './color';
export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => { export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui'; import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconFont, IconMark } from '@douyinfe/semi-icons'; import { IconFont, IconMark } from '@douyinfe/semi-icons';
import { isTitleActive } from '../utils/active'; import { isTitleActive } from './utils/active';
import { Color } from '../components/color'; import { Color } from './components/color';
export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => { export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
const { color, backgroundColor } = editor.getAttributes('textStyle'); const { color, backgroundColor } = editor.getAttributes('textStyle');

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Select } from '@douyinfe/semi-ui'; import { Select } from '@douyinfe/semi-ui';
import { isTitleActive } from '../../utils/active'; import { isTitleActive } from '../utils/active';
export const FONT_SIZES = [12, 13, 14, 15, 16, 19, 22, 24, 29, 32, 40, 48]; export const FONT_SIZES = [12, 13, 14, 15, 16, 19, 22, 24, 29, 32, 40, 48];

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Select } from '@douyinfe/semi-ui'; import { Select } from '@douyinfe/semi-ui';
import { isTitleActive } from '../../utils/active'; import { isTitleActive } from '../utils/active';
const getCurrentCaretTitle = (editor) => { const getCurrentCaretTitle = (editor) => {
if (editor.isActive('heading', { level: 1 })) return 1; if (editor.isActive('heading', { level: 1 })) return 1;

View File

@ -8,10 +8,10 @@ import {
IconDelete, IconDelete,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { Upload } from 'components/upload'; import { Upload } from 'components/upload';
import { BubbleMenu } from '../components/bubble-menu'; import { BubbleMenu } from './components/bubble-menu';
import { Divider } from '../components/divider'; import { Divider } from '../components/divider';
import { Image } from '../extensions/image'; import { Image } from '../extensions/image';
import { getImageOriginSize } from '../utils/image'; import { getImageOriginSize } from './utils/image';
const { Text } = Typography; const { Text } = Typography;

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Space, Button, Tooltip, Input } from '@douyinfe/semi-ui'; import { Space, Button, Tooltip, Input } from '@douyinfe/semi-ui';
import { IconExternalOpen, IconUnlink, IconTickCircle } from '@douyinfe/semi-icons'; import { IconExternalOpen, IconUnlink, IconTickCircle } from '@douyinfe/semi-icons';
import { BubbleMenu } from '../components/bubble-menu'; import { BubbleMenu } from './components/bubble-menu';
import { Link } from '../extensions/link'; import { Link } from '../extensions/link';
export const LinkBubbleMenu = ({ editor }) => { export const LinkBubbleMenu = ({ editor }) => {

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui'; import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons'; import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons';
import { IconTask } from 'components/icons'; import { IconTask } from 'components/icons';
import { isTitleActive } from '../utils/active'; import { isTitleActive } from './utils/active';
export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => { export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
if (!editor) { if (!editor) {

Some files were not shown because too many files have changed in this diff Show More