mirror of https://github.com/fantasticit/think.git
refactor: refactor the tiptap code
This commit is contained in:
parent
0353d20dcf
commit
c94fe79804
|
@ -32,6 +32,7 @@
|
|||
"@tiptap/extension-hard-break": "^2.0.0-beta.30",
|
||||
"@tiptap/extension-heading": "^2.0.0-beta.26",
|
||||
"@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-italic": "^2.0.0-beta.25",
|
||||
"@tiptap/extension-link": "^2.0.0-beta.36",
|
||||
|
@ -51,6 +52,7 @@
|
|||
"@tiptap/extension-text-style": "^2.0.0-beta.23",
|
||||
"@tiptap/extension-underline": "^2.0.0-beta.23",
|
||||
"@tiptap/react": "^2.0.0-beta.107",
|
||||
"@traptitech/markdown-it-katex": "^3.5.0",
|
||||
"axios": "^0.25.0",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
|
@ -59,6 +61,12 @@
|
|||
"interactjs": "^1.10.11",
|
||||
"katex": "^0.15.2",
|
||||
"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",
|
||||
"next": "12.0.10",
|
||||
"prosemirror-markdown": "^1.7.0",
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
getProvider,
|
||||
destoryProvider,
|
||||
MenuBar,
|
||||
Toc,
|
||||
} from 'components/tiptap';
|
||||
import { DataRender } from 'components/data-render';
|
||||
import { joinUser } from 'components/document/collaboration';
|
||||
|
|
|
@ -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,
|
||||
];
|
|
@ -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,
|
||||
];
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -8,7 +8,7 @@ import { lowlight } from 'lowlight';
|
|||
import { copy } from 'helpers/copy';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const Render = ({
|
||||
export const CodeBlockWrapper = ({
|
||||
editor,
|
||||
node: {
|
||||
attrs: { language: defaultLanguage },
|
||||
|
@ -53,9 +53,3 @@ const Render = ({
|
|||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
},
|
||||
}).configure({ lowlight });
|
|
@ -1,15 +1,7 @@
|
|||
import {
|
||||
Node,
|
||||
Command,
|
||||
mergeAttributes,
|
||||
textInputRule,
|
||||
textblockTypeInputRule,
|
||||
wrappingInputRule,
|
||||
} from '@tiptap/core';
|
||||
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { useRouter } from 'next/router';
|
||||
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 { DataRender } from 'components/data-render';
|
||||
import { Empty } from 'components/empty';
|
||||
|
@ -18,70 +10,7 @@ import styles from './index.module.scss';
|
|||
|
||||
const { Text } = Typography;
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
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 = () => {
|
||||
export const DocumentChildrenWrapper = () => {
|
||||
const { pathname, query } = useRouter();
|
||||
const wikiId = query?.wikiId;
|
||||
const documentId = query?.documentId;
|
||||
|
@ -135,9 +64,3 @@ const Render = () => {
|
|||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentChildren = DocumentChildrenExtension.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
},
|
||||
});
|
|
@ -1,5 +1,4 @@
|
|||
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Select } from '@douyinfe/semi-ui';
|
||||
|
@ -8,73 +7,7 @@ import { DataRender } from 'components/data-render';
|
|||
import { IconDocument } from 'components/icons';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
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 }) => {
|
||||
export const DocumentReferenceWrapper = ({ editor, node, updateAttributes }) => {
|
||||
const { pathname, query } = useRouter();
|
||||
const wikiIdFromUrl = query?.wikiId;
|
||||
const isShare = pathname.includes('share');
|
||||
|
@ -83,7 +16,7 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
const { data: tocs, loading, error } = useWikiTocs(isShare ? null : wikiIdFromUrl);
|
||||
|
||||
const selectDoc = (str) => {
|
||||
const [wikiId, documentId, title] = str.split('/');
|
||||
const [wikiId, title, documentId] = str.split('/');
|
||||
updateAttributes({ wikiId, documentId, title });
|
||||
};
|
||||
|
||||
|
@ -98,12 +31,13 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
<Select
|
||||
placeholder="请选择文档"
|
||||
onChange={(v) => selectDoc(v)}
|
||||
{...(wikiId && documentId ? { value: `${wikiId}/${documentId}/${title}` } : {})}
|
||||
{...(wikiId && documentId ? { value: `${wikiId}/${title}/${documentId}` } : {})}
|
||||
>
|
||||
{(tocs || []).map((toc) => (
|
||||
<Select.Option
|
||||
label={`${toc.id}/${toc.title}`}
|
||||
value={`${toc.wikiId}/${toc.id}/${toc.title}`}
|
||||
// FIXME: semi-design 抄 antd,抄的什么玩意!!!
|
||||
label={`${toc.title}/${toc.id}`}
|
||||
value={`${toc.wikiId}/${toc.title}/${toc.id}`}
|
||||
>
|
||||
{toc.title}
|
||||
</Select.Option>
|
||||
|
@ -129,9 +63,3 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentReference = DocumentReferenceExtension.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
},
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,8 +1,7 @@
|
|||
import { Image as TImage } from '@tiptap/extension-image';
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { NodeViewWrapper } from '@tiptap/react';
|
||||
import { Resizeable } from 'components/resizeable';
|
||||
|
||||
const Render = ({ editor, node, updateAttributes }) => {
|
||||
export const ImageWrapper = ({ editor, node, updateAttributes }) => {
|
||||
const isEditable = editor.isEditable;
|
||||
const { src, alt, title, width, height, textAlign } = node.attrs;
|
||||
|
||||
|
@ -24,29 +23,3 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
</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);
|
||||
},
|
||||
});
|
|
@ -1,77 +1,13 @@
|
|||
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { useMemo } from 'react';
|
||||
import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import katex from 'katex';
|
||||
import styles from './index.module.scss';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
katex: {
|
||||
setKatex: () => Command;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export const KatexInputRegex = /^\$\$(.+)?\$$/;
|
||||
|
||||
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 }) => {
|
||||
export const KatexWrapper = ({ editor, node, updateAttributes }) => {
|
||||
const isEditable = editor.isEditable;
|
||||
const { text } = node.attrs;
|
||||
const formatText = useMemo(() => {
|
||||
|
@ -123,9 +59,3 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Katex = KatexExtension.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
},
|
||||
});
|
|
@ -1,5 +1,4 @@
|
|||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconMinus, IconPlus } from '@douyinfe/semi-icons';
|
||||
|
@ -9,84 +8,7 @@ import deepEqual from 'deep-equal';
|
|||
import jsMind from './jsmind.jsx';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const DEFAULT_MIND_DATA = {
|
||||
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 }) => {
|
||||
export const MindWrapper = ({ editor, node, updateAttributes }) => {
|
||||
const $container = useRef();
|
||||
const $mind = useRef<any>();
|
||||
const isEditable = editor.isEditable;
|
||||
|
@ -216,9 +138,3 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Mind = MindNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
},
|
||||
});
|
|
@ -2,7 +2,7 @@ import ReactDOM from 'react-dom';
|
|||
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { IconZoomOut, IconZoomIn } from 'components/icons';
|
||||
import { Divider } from '../../components/divider';
|
||||
import { Divider } from '../divider';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
/*
|
|
@ -1,61 +1,8 @@
|
|||
import { Node, Command, mergeAttributes } from '@tiptap/core';
|
||||
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { Space, Popover, Tag, Input } from '@douyinfe/semi-ui';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
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 }) => {
|
||||
export const StatusWrapper = ({ editor, node, updateAttributes }) => {
|
||||
const isEditable = editor.isEditable;
|
||||
const { color, text } = node.attrs;
|
||||
const content = <Tag color={color}>{text || '设置状态'}</Tag>;
|
||||
|
@ -101,9 +48,3 @@ const Render = ({ editor, node, updateAttributes }) => {
|
|||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Status = StatusExtension.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
},
|
||||
});
|
|
@ -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;
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -8,13 +8,7 @@ export type ColorOptions = {
|
|||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
backgroundColor: {
|
||||
/**
|
||||
* Set the text color
|
||||
*/
|
||||
setBackgroundColor: (color: string) => ReturnType;
|
||||
/**
|
||||
* Unset the text color
|
||||
*/
|
||||
unsetBackgroundColor: () => ReturnType;
|
||||
};
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import { Node, Command, mergeAttributes } from '@tiptap/core';
|
||||
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { Banner as SemiBanner } from '@douyinfe/semi-ui';
|
||||
import styles from './index.module.scss';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { BannerWrapper } from '../components/banner';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
|
@ -11,7 +10,7 @@ declare module '@tiptap/core' {
|
|||
}
|
||||
}
|
||||
|
||||
const BannerExtension = Node.create({
|
||||
export const Banner = Node.create({
|
||||
name: 'banner',
|
||||
content: '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() {
|
||||
return ReactNodeViewRenderer(Render);
|
||||
return ReactNodeViewRenderer(BannerWrapper);
|
||||
},
|
||||
});
|
|
@ -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 }),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Bold } from '@tiptap/extension-bold';
|
|
@ -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 : '*';
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import { Color } from '@tiptap/extension-color';
|
||||
|
||||
export { Color };
|
|
@ -1,6 +1,6 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import findColors from '../utils/find-colors';
|
||||
import { findColors } from '../services/color';
|
||||
|
||||
export const ColorHighlighter = Extension.create({
|
||||
name: 'colorHighlighter',
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Document } from '@tiptap/extension-document';
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Dropcursor } from '@tiptap/extension-dropcursor';
|
|
@ -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];
|
||||
},
|
||||
});
|
|
@ -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];
|
||||
},
|
||||
});
|
|
@ -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];
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Gapcursor } from '@tiptap/extension-gapcursor';
|
|
@ -0,0 +1,3 @@
|
|||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
|
||||
export { HardBreak };
|
|
@ -0,0 +1 @@
|
|||
export { Heading } from '@tiptap/extension-heading';
|
|
@ -8,9 +8,6 @@ export interface HorizontalRuleOptions {
|
|||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
horizontalRule: {
|
||||
/**
|
||||
* Add a horizontal rule
|
||||
*/
|
||||
setHorizontalRule: () => ReturnType;
|
||||
};
|
||||
}
|
||||
|
@ -41,10 +38,8 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
|
|||
setHorizontalRule:
|
||||
() =>
|
||||
({ chain }) => {
|
||||
return (
|
||||
chain()
|
||||
return chain()
|
||||
.insertContent({ type: this.name })
|
||||
// set cursor after horizontal rule
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const { $to } = tr.selection;
|
||||
|
@ -53,7 +48,6 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
|
|||
if ($to.nodeAfter) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
|
||||
} else {
|
||||
// add node after horizontal rule if it’s the end of the document
|
||||
const node = $to.parent.type.contentMatch.defaultType?.create();
|
||||
|
||||
if (node) {
|
||||
|
@ -67,8 +61,7 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
|
|||
|
||||
return true;
|
||||
})
|
||||
.run()
|
||||
);
|
||||
.run();
|
||||
},
|
||||
};
|
||||
},
|
|
@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
})
|
||||
);
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Italic } from '@tiptap/extension-italic';
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
import { Link as TLink } from '@tiptap/extension-link';
|
||||
|
||||
export const Link = TLink.extend({});
|
|
@ -0,0 +1 @@
|
|||
export { ListItem } from '@tiptap/extension-list-item';
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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)),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Paragraph } from '@tiptap/extension-paragraph';
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
|
||||
export { Placeholder };
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Strike } from '@tiptap/extension-strike';
|
|
@ -0,0 +1,5 @@
|
|||
import { Table as BuiltInTable } from '@tiptap/extension-table';
|
||||
|
||||
export const Table = BuiltInTable.configure({
|
||||
resizable: true,
|
||||
});
|
|
@ -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 };
|
|
@ -0,0 +1 @@
|
|||
export { TableCell } from '@tiptap/extension-table-cell';
|
|
@ -0,0 +1 @@
|
|||
export { TableHeader } from '@tiptap/extension-table-header';
|
|
@ -0,0 +1,5 @@
|
|||
import { TableRow as BuiltInTableRow } from '@tiptap/extension-table-row';
|
||||
|
||||
export const TableRow = BuiltInTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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];
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Text } from '@tiptap/extension-text';
|
|
@ -0,0 +1,3 @@
|
|||
import TextAlign from '@tiptap/extension-text-align';
|
||||
|
||||
export { TextAlign };
|
|
@ -0,0 +1,3 @@
|
|||
import TextStyle from '@tiptap/extension-text-style';
|
||||
|
||||
export { TextStyle };
|
|
@ -1,7 +1,6 @@
|
|||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import Document from '@tiptap/extension-document';
|
||||
|
||||
const Title = Node.create({
|
||||
export const Title = Node.create({
|
||||
name: 'title',
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
|
@ -26,9 +25,3 @@ const Title = Node.create({
|
|||
return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
});
|
||||
|
||||
const TitledDocument = Document.extend({
|
||||
content: 'title block+',
|
||||
});
|
||||
|
||||
export { Document, Title, TitledDocument };
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import Underline from '@tiptap/extension-underline';
|
||||
|
||||
export { Underline };
|
|
@ -1,17 +1,22 @@
|
|||
import { BaseExtension, Document, TitledDocument as DocumentWithTitle } from './base-kit';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import Collaboration from '@tiptap/extension-collaboration';
|
||||
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
|
||||
import { getRandomColor } from 'helpers/color';
|
||||
import { Document } from './extensions/document';
|
||||
import { BaseKit } from './basekit';
|
||||
|
||||
export { getSchema } from '@tiptap/core';
|
||||
export * from './menubar';
|
||||
export * from './provider';
|
||||
export * from './skeleton';
|
||||
export * from './toc';
|
||||
|
||||
export { Document, DocumentWithTitle };
|
||||
export const DEFAULT_EXTENSION = [...BaseExtension];
|
||||
export const DocumentWithTitle = Document.extend({
|
||||
content: 'title block+',
|
||||
});
|
||||
|
||||
export { Document };
|
||||
export const DEFAULT_EXTENSION = [...BaseKit];
|
||||
|
||||
export const getCollaborationExtension = (provider: HocuspocusProvider) => {
|
||||
return Collaboration.configure({
|
||||
document: provider.document,
|
|
@ -1 +0,0 @@
|
|||
export * from './serializer';
|
|
@ -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();
|
|
@ -6,7 +6,7 @@ import {
|
|||
IconAlignRight,
|
||||
IconAlignJustify,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../utils/active';
|
||||
import { isTitleActive } from './utils/active';
|
||||
|
||||
export const AlignMenu = ({ editor }) => {
|
||||
const current = (() => {
|
||||
|
|
|
@ -6,10 +6,10 @@ import {
|
|||
IconClear,
|
||||
IconInfoCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { BubbleMenu } from '../components/bubble-menu';
|
||||
import { BubbleMenu } from './components/bubble-menu';
|
||||
import { Divider } from '../components/divider';
|
||||
import { Banner } from '../extensions/banner';
|
||||
import { deleteNode } from '../utils/delete';
|
||||
import { deleteNode } from './utils/delete';
|
||||
|
||||
export const BannerBubbleMenu = ({ editor }) => {
|
||||
return (
|
||||
|
|
|
@ -10,8 +10,8 @@ import { Iframe } from '../extensions/iframe';
|
|||
import { Mind } from '../extensions/mind';
|
||||
import { Table } from '../extensions/table';
|
||||
import { Katex } from '../extensions/katex';
|
||||
import { DocumentReference } from '../extensions/documents/reference';
|
||||
import { DocumentChildren } from '../extensions/documents/children';
|
||||
import { DocumentReference } from '../extensions/documentReference';
|
||||
import { DocumentChildren } from '../extensions/documentChildren';
|
||||
import { BaseMenu } from './base-menu';
|
||||
|
||||
const OTHER_BUBBLE_MENU_TYPES = [
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../utils/active';
|
||||
import { isTitleActive } from './utils/active';
|
||||
import { Emoji } from './components/emoji';
|
||||
|
||||
export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
IconUnderline,
|
||||
IconCode,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../utils/active';
|
||||
import { isTitleActive } from './utils/active';
|
||||
import { ColorMenu } from './color';
|
||||
|
||||
export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconFont, IconMark } from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../utils/active';
|
||||
import { Color } from '../components/color';
|
||||
import { isTitleActive } from './utils/active';
|
||||
import { Color } from './components/color';
|
||||
|
||||
export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
const { color, backgroundColor } = editor.getAttributes('textStyle');
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
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];
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Select } from '@douyinfe/semi-ui';
|
||||
import { isTitleActive } from '../../utils/active';
|
||||
import { isTitleActive } from '../utils/active';
|
||||
|
||||
const getCurrentCaretTitle = (editor) => {
|
||||
if (editor.isActive('heading', { level: 1 })) return 1;
|
||||
|
|
|
@ -8,10 +8,10 @@ import {
|
|||
IconDelete,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Upload } from 'components/upload';
|
||||
import { BubbleMenu } from '../components/bubble-menu';
|
||||
import { BubbleMenu } from './components/bubble-menu';
|
||||
import { Divider } from '../components/divider';
|
||||
import { Image } from '../extensions/image';
|
||||
import { getImageOriginSize } from '../utils/image';
|
||||
import { getImageOriginSize } from './utils/image';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Space, Button, Tooltip, Input } from '@douyinfe/semi-ui';
|
||||
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';
|
||||
|
||||
export const LinkBubbleMenu = ({ editor }) => {
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons';
|
||||
import { IconTask } from 'components/icons';
|
||||
import { isTitleActive } from '../utils/active';
|
||||
import { isTitleActive } from './utils/active';
|
||||
|
||||
export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
if (!editor) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue