mirror of https://github.com/fantasticit/think.git
Merge pull request #5 from fantasticit/paste
This commit is contained in:
commit
5623d6aff3
|
@ -53,7 +53,6 @@
|
||||||
"@tiptap/extension-underline": "^2.0.0-beta.23",
|
"@tiptap/extension-underline": "^2.0.0-beta.23",
|
||||||
"@tiptap/react": "^2.0.0-beta.107",
|
"@tiptap/react": "^2.0.0-beta.107",
|
||||||
"@tiptap/suggestion": "^2.0.0-beta.90",
|
"@tiptap/suggestion": "^2.0.0-beta.90",
|
||||||
"@traptitech/markdown-it-katex": "^3.5.0",
|
|
||||||
"axios": "^0.25.0",
|
"axios": "^0.25.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"copy-to-clipboard": "^3.3.1",
|
"copy-to-clipboard": "^3.3.1",
|
||||||
|
@ -66,11 +65,8 @@
|
||||||
"markdown-it-anchor": "^8.4.1",
|
"markdown-it-anchor": "^8.4.1",
|
||||||
"markdown-it-container": "^3.0.0",
|
"markdown-it-container": "^3.0.0",
|
||||||
"markdown-it-emoji": "^2.0.0",
|
"markdown-it-emoji": "^2.0.0",
|
||||||
"markdown-it-footnote": "^3.0.3",
|
|
||||||
"markdown-it-sub": "^1.0.0",
|
"markdown-it-sub": "^1.0.0",
|
||||||
"markdown-it-sup": "^1.0.0",
|
"markdown-it-sup": "^1.0.0",
|
||||||
"markdown-it-task-lists": "^2.1.1",
|
|
||||||
"marked": "^4.0.12",
|
|
||||||
"next": "12.0.10",
|
"next": "12.0.10",
|
||||||
"prosemirror-markdown": "^1.7.0",
|
"prosemirror-markdown": "^1.7.0",
|
||||||
"prosemirror-tables": "^1.1.1",
|
"prosemirror-tables": "^1.1.1",
|
||||||
|
|
|
@ -78,8 +78,8 @@ export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId })
|
||||||
CollaborationEventEmitter.on(KEY, ({ states: users }) => {
|
CollaborationEventEmitter.on(KEY, ({ states: users }) => {
|
||||||
const newCollaborationUsers = users
|
const newCollaborationUsers = users
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((state) => ({ ...state.user, clientId: state.clientId }))
|
.filter((state) => state.user)
|
||||||
.filter(Boolean);
|
.map((state) => ({ ...state.user, clientId: state.clientId }));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
collaborationUsers.length === newCollaborationUsers.length &&
|
collaborationUsers.length === newCollaborationUsers.length &&
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import React, { useMemo, useEffect } from 'react';
|
import React, { useMemo, useEffect } from 'react';
|
||||||
import cls from 'classnames';
|
import cls from 'classnames';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
import { Layout, Nav, BackTop, Toast } from '@douyinfe/semi-ui';
|
import { BackTop } from '@douyinfe/semi-ui';
|
||||||
import { ILoginUser, IAuthority } from '@think/domains';
|
import { ILoginUser, IAuthority } from '@think/domains';
|
||||||
import { useToggle } from 'hooks/useToggle';
|
import { useToggle } from 'hooks/useToggle';
|
||||||
import {
|
import {
|
||||||
DEFAULT_EXTENSION,
|
DEFAULT_EXTENSION,
|
||||||
Document,
|
|
||||||
DocumentWithTitle,
|
DocumentWithTitle,
|
||||||
getCollaborationExtension,
|
getCollaborationExtension,
|
||||||
getCollaborationCursorExtension,
|
getCollaborationCursorExtension,
|
||||||
|
@ -16,10 +15,10 @@ import {
|
||||||
} from 'components/tiptap';
|
} from 'components/tiptap';
|
||||||
import { DataRender } from 'components/data-render';
|
import { DataRender } from 'components/data-render';
|
||||||
import { joinUser } from 'components/document/collaboration';
|
import { joinUser } from 'components/document/collaboration';
|
||||||
|
import { debounce } from 'helpers/debounce';
|
||||||
|
import { changeTitle } from './index';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
user: ILoginUser;
|
user: ILoginUser;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
@ -46,10 +45,6 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
});
|
});
|
||||||
}, [documentId, user.token]);
|
}, [documentId, user.token]);
|
||||||
|
|
||||||
const noTitleEditor = useEditor({
|
|
||||||
extensions: [...DEFAULT_EXTENSION, Document],
|
|
||||||
});
|
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editable: authority && authority.editable,
|
editable: authority && authority.editable,
|
||||||
extensions: [
|
extensions: [
|
||||||
|
@ -58,10 +53,12 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
getCollaborationExtension(provider),
|
getCollaborationExtension(provider),
|
||||||
getCollaborationCursorExtension(provider, user),
|
getCollaborationCursorExtension(provider, user),
|
||||||
],
|
],
|
||||||
editorProps: {
|
onTransaction: debounce(({ transaction }) => {
|
||||||
// @ts-ignore
|
try {
|
||||||
noTitleEditor,
|
const title = transaction.doc.content.firstChild.content.firstChild.textContent;
|
||||||
},
|
changeTitle(title);
|
||||||
|
} catch (e) {}
|
||||||
|
}, 200),
|
||||||
});
|
});
|
||||||
const [loading, toggleLoading] = useToggle(true);
|
const [loading, toggleLoading] = useToggle(true);
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,12 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
> header {
|
> header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 110;
|
||||||
|
background-color: var(--semi-color-nav-bg);
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
@ -27,13 +32,15 @@
|
||||||
|
|
||||||
> header {
|
> header {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10001;
|
z-index: 110;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: var(--semi-color-nav-bg);
|
||||||
border-bottom: 1px solid var(--semi-color-border);
|
border-bottom: 1px solid var(--semi-color-border);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&.isStandardWidth {
|
&.isStandardWidth {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Router from 'next/router';
|
import Router from 'next/router';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Layout, Nav, Skeleton, Typography, Space, Button, Tooltip, Spin, Popover } from '@douyinfe/semi-ui';
|
import { Nav, Skeleton, Typography, Space, Button, Tooltip, Spin, Popover } from '@douyinfe/semi-ui';
|
||||||
import { IconChevronLeft, IconArticle } from '@douyinfe/semi-icons';
|
import { IconChevronLeft, IconArticle } from '@douyinfe/semi-icons';
|
||||||
import { useUser } from 'data/user';
|
import { useUser } from 'data/user';
|
||||||
import { useDocumentDetail } from 'data/document';
|
import { useDocumentDetail } from 'data/document';
|
||||||
|
@ -13,12 +13,19 @@ import { DocumentStar } from 'components/document/star';
|
||||||
import { DocumentCollaboration } from 'components/document/collaboration';
|
import { DocumentCollaboration } from 'components/document/collaboration';
|
||||||
import { DocumentStyle } from 'components/document/style';
|
import { DocumentStyle } from 'components/document/style';
|
||||||
import { useDocumentStyle } from 'hooks/useDocumentStyle';
|
import { useDocumentStyle } from 'hooks/useDocumentStyle';
|
||||||
|
import { EventEmitter } from 'helpers/event-emitter';
|
||||||
import { Editor } from './editor';
|
import { Editor } from './editor';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const em = new EventEmitter();
|
||||||
|
const TITLE_CHANGE_EVENT = 'TITLE_CHANGE_EVENT';
|
||||||
|
|
||||||
|
export const changeTitle = (title) => {
|
||||||
|
em.emit(TITLE_CHANGE_EVENT, title);
|
||||||
|
};
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
}
|
}
|
||||||
|
@ -30,7 +37,7 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
|
||||||
const editorWrapClassNames = useMemo(() => {
|
const editorWrapClassNames = useMemo(() => {
|
||||||
return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth;
|
return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth;
|
||||||
}, [width]);
|
}, [width]);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { data: documentAndAuth, loading: docAuthLoading, error: docAuthError } = useDocumentDetail(documentId);
|
const { data: documentAndAuth, loading: docAuthLoading, error: docAuthError } = useDocumentDetail(documentId);
|
||||||
const { document, authority } = documentAndAuth || {};
|
const { document, authority } = documentAndAuth || {};
|
||||||
|
@ -54,13 +61,21 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
|
||||||
}
|
}
|
||||||
normalContent={() => (
|
normalContent={() => (
|
||||||
<Text ellipsis={{ showTooltip: true }} style={{ width: ~~(windowWith / 4) }}>
|
<Text ellipsis={{ showTooltip: true }} style={{ width: ~~(windowWith / 4) }}>
|
||||||
{document.title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
em.on(TITLE_CHANGE_EVENT, setTitle);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
em.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<header>
|
<header>
|
||||||
|
@ -91,10 +106,9 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
|
||||||
<Spin></Spin>
|
<Spin></Spin>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
error={null}
|
error={docAuthError}
|
||||||
normalContent={() => {
|
normalContent={() => {
|
||||||
return (
|
return (
|
||||||
// <div style={{ fontSize }}>
|
|
||||||
<>
|
<>
|
||||||
<Seo title={document.title} />
|
<Seo title={document.title} />
|
||||||
<Editor
|
<Editor
|
||||||
|
|
|
@ -43,8 +43,6 @@ export const CreateUser: React.FC<{ document: IDocument; container: () => HTMLEl
|
||||||
|
|
||||||
const el = container && container();
|
const el = container && container();
|
||||||
|
|
||||||
console.log(el);
|
|
||||||
|
|
||||||
if (!el) return content;
|
if (!el) return content;
|
||||||
return createPortal(content, el);
|
return createPortal(content, el);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,7 +5,12 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
> header {
|
> header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 110;
|
||||||
|
background-color: var(--semi-color-nav-bg);
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
@ -27,13 +32,15 @@
|
||||||
|
|
||||||
> header {
|
> header {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10001;
|
z-index: 110;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: var(--semi-color-nav-bg);
|
||||||
border-bottom: 1px solid var(--semi-color-border);
|
border-bottom: 1px solid var(--semi-color-border);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&.isStandardWidth {
|
&.isStandardWidth {
|
||||||
> div {
|
> div {
|
||||||
|
|
|
@ -15,9 +15,6 @@ import { Emoji } from './extensions/emoji';
|
||||||
import { EvokeMenu } from './extensions/evokeMenu';
|
import { EvokeMenu } from './extensions/evokeMenu';
|
||||||
import { Focus } from './extensions/focus';
|
import { Focus } from './extensions/focus';
|
||||||
import { FontSize } from './extensions/fontSize';
|
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 { Gapcursor } from './extensions/gapCursor';
|
||||||
import { HardBreak } from './extensions/hardBreak';
|
import { HardBreak } from './extensions/hardBreak';
|
||||||
import { Heading } from './extensions/heading';
|
import { Heading } from './extensions/heading';
|
||||||
|
@ -34,7 +31,6 @@ import { Loading } from './extensions/loading';
|
||||||
import { Mind } from './extensions/mind';
|
import { Mind } from './extensions/mind';
|
||||||
import { OrderedList } from './extensions/orderedList';
|
import { OrderedList } from './extensions/orderedList';
|
||||||
import { Paragraph } from './extensions/paragraph';
|
import { Paragraph } from './extensions/paragraph';
|
||||||
import { Paste } from './extensions/paste';
|
|
||||||
import { Placeholder } from './extensions/placeholder';
|
import { Placeholder } from './extensions/placeholder';
|
||||||
import { SearchNReplace } from './extensions/search';
|
import { SearchNReplace } from './extensions/search';
|
||||||
import { Status } from './extensions/status';
|
import { Status } from './extensions/status';
|
||||||
|
@ -51,6 +47,7 @@ import { TaskList } from './extensions/taskList';
|
||||||
import { Title } from './extensions/title';
|
import { Title } from './extensions/title';
|
||||||
import { TrailingNode } from './extensions/trailingNode';
|
import { TrailingNode } from './extensions/trailingNode';
|
||||||
import { Underline } from './extensions/underline';
|
import { Underline } from './extensions/underline';
|
||||||
|
import { Paste } from './extensions/paste';
|
||||||
|
|
||||||
export const BaseKit = [
|
export const BaseKit = [
|
||||||
Attachment,
|
Attachment,
|
||||||
|
@ -70,9 +67,6 @@ export const BaseKit = [
|
||||||
EvokeMenu,
|
EvokeMenu,
|
||||||
Focus,
|
Focus,
|
||||||
FontSize,
|
FontSize,
|
||||||
FootnoteDefinition,
|
|
||||||
FootnoteReference,
|
|
||||||
FootnotesSection,
|
|
||||||
Gapcursor,
|
Gapcursor,
|
||||||
HardBreak,
|
HardBreak,
|
||||||
Heading,
|
Heading,
|
||||||
|
@ -89,7 +83,6 @@ export const BaseKit = [
|
||||||
Mind,
|
Mind,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Paste,
|
|
||||||
Placeholder,
|
Placeholder,
|
||||||
SearchNReplace,
|
SearchNReplace,
|
||||||
Status,
|
Status,
|
||||||
|
@ -106,4 +99,5 @@ export const BaseKit = [
|
||||||
Title,
|
Title,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
Underline,
|
Underline,
|
||||||
|
Paste,
|
||||||
];
|
];
|
||||||
|
|
|
@ -49,7 +49,7 @@ const getFileTypeIcon = (type: FileType) => {
|
||||||
export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
|
export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const $upload = useRef();
|
const $upload = useRef();
|
||||||
const isEditable = editor.isEditable;
|
const isEditable = editor.isEditable;
|
||||||
const { autoTrigger, fileName, fileSize, fileExt, fileType, url, error } = node.attrs;
|
const { hasTrigger, fileName, fileSize, fileExt, fileType, url, error } = node.attrs;
|
||||||
const [loading, toggleLoading] = useToggle(false);
|
const [loading, toggleLoading] = useToggle(false);
|
||||||
const [visible, toggleVisible] = useToggle(false);
|
const [visible, toggleVisible] = useToggle(false);
|
||||||
|
|
||||||
|
@ -81,11 +81,11 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const type = normalizeFileType(fileType);
|
const type = normalizeFileType(fileType);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url && !autoTrigger) {
|
if (!url && !hasTrigger) {
|
||||||
selectFile();
|
selectFile();
|
||||||
updateAttributes({ autoTrigger: true });
|
updateAttributes({ hasTrigger: true });
|
||||||
}
|
}
|
||||||
}, [url, autoTrigger]);
|
}, [url, hasTrigger]);
|
||||||
|
|
||||||
const content = (() => {
|
const content = (() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
@ -8,17 +8,31 @@ import { DataRender } from 'components/data-render';
|
||||||
import { Empty } from 'components/empty';
|
import { Empty } from 'components/empty';
|
||||||
import { IconDocument } from 'components/icons';
|
import { IconDocument } from 'components/icons';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export const DocumentChildrenWrapper = ({ editor }) => {
|
export const DocumentChildrenWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const isEditable = editor.isEditable;
|
const isEditable = editor.isEditable;
|
||||||
const { pathname, query } = useRouter();
|
const { pathname, query } = useRouter();
|
||||||
const wikiId = query?.wikiId;
|
let { wikiId, documentId } = node.attrs;
|
||||||
const documentId = query?.documentId;
|
if (!wikiId) {
|
||||||
|
query?.wikiId;
|
||||||
|
}
|
||||||
|
if (!documentId) {
|
||||||
|
documentId = query?.documentId;
|
||||||
|
}
|
||||||
const isShare = pathname.includes('share');
|
const isShare = pathname.includes('share');
|
||||||
const { data: documents, loading, error } = useChildrenDocument({ wikiId, documentId, isShare });
|
const { data: documents, loading, error } = useChildrenDocument({ wikiId, documentId, isShare });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const attrs = node.attrs;
|
||||||
|
|
||||||
|
if (attrs.wikiId !== wikiId || attrs.documentId !== documentId) {
|
||||||
|
updateAttributes({ wikiId, documentId });
|
||||||
|
}
|
||||||
|
}, [node.attrs, wikiId, documentId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper as="div" className={cls(styles.wrap, isEditable && styles.isEditable)}>
|
<NodeViewWrapper as="div" className={cls(styles.wrap, isEditable && styles.isEditable)}>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -7,6 +7,8 @@ export const IframeWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const isEditable = editor.isEditable;
|
const isEditable = editor.isEditable;
|
||||||
const { url, width, height } = node.attrs;
|
const { url, width, height } = node.attrs;
|
||||||
|
|
||||||
|
console.log('render iframe', node.attrs);
|
||||||
|
|
||||||
const onResize = (size) => {
|
const onResize = (size) => {
|
||||||
updateAttributes({ width: size.width, height: size.height });
|
updateAttributes({ width: size.width, height: size.height });
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,7 +11,7 @@ const { Text } = Typography;
|
||||||
|
|
||||||
export const ImageWrapper = ({ editor, node, updateAttributes }) => {
|
export const ImageWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const isEditable = editor.isEditable;
|
const isEditable = editor.isEditable;
|
||||||
const { autoTrigger, error, src, alt, title, width, height, textAlign } = node.attrs;
|
const { hasTrigger, error, src, alt, title, width, height, textAlign } = node.attrs;
|
||||||
const $upload = useRef();
|
const $upload = useRef();
|
||||||
const [loading, toggleLoading] = useToggle(false);
|
const [loading, toggleLoading] = useToggle(false);
|
||||||
|
|
||||||
|
@ -45,11 +45,11 @@ export const ImageWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!src && !autoTrigger) {
|
if (!src && !hasTrigger) {
|
||||||
selectFile();
|
selectFile();
|
||||||
updateAttributes({ autoTrigger: true });
|
updateAttributes({ hasTrigger: true });
|
||||||
}
|
}
|
||||||
}, [src, autoTrigger]);
|
}, [src, hasTrigger]);
|
||||||
|
|
||||||
const content = (() => {
|
const content = (() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui';
|
import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui';
|
||||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
|
@ -10,6 +10,7 @@ const { Text } = Typography;
|
||||||
export const KatexWrapper = ({ editor, node, updateAttributes }) => {
|
export const KatexWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const isEditable = editor.isEditable;
|
const isEditable = editor.isEditable;
|
||||||
const { text } = node.attrs;
|
const { text } = node.attrs;
|
||||||
|
|
||||||
const formatText = useMemo(() => {
|
const formatText = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return katex.renderToString(`${text}`);
|
return katex.renderToString(`${text}`);
|
||||||
|
@ -25,7 +26,7 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper as="div" className={styles.wrap} contentEditable={false}>
|
<NodeViewWrapper as="span" className={styles.wrap} contentEditable={false}>
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
<Popover
|
<Popover
|
||||||
showArrow
|
showArrow
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
.items {
|
.items {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
max-height: 50vh;
|
max-height: 40vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
.wrap {
|
||||||
|
margin: 8px 0;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui';
|
||||||
|
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||||
|
import katex from 'katex';
|
||||||
|
import { Checkbox } from '@douyinfe/semi-ui';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export const TaskItemWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
|
const isEditable = editor.isEditable;
|
||||||
|
const { checked } = node.attrs;
|
||||||
|
|
||||||
|
console.log(node.attrs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className={styles.wrap} contentEditable={false}>
|
||||||
|
<Checkbox checked={checked} onChange={(e) => updateAttributes({ checked: e.target.checked })} />
|
||||||
|
<NodeViewContent></NodeViewContent>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,6 +1,7 @@
|
||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { AttachmentWrapper } from '../components/attachment';
|
import { AttachmentWrapper } from '../components/attachment';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -38,27 +39,35 @@ export const Attachment = Node.create({
|
||||||
return {
|
return {
|
||||||
fileName: {
|
fileName: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('filename'),
|
||||||
},
|
},
|
||||||
fileSize: {
|
fileSize: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('filesize'),
|
||||||
},
|
},
|
||||||
fileType: {
|
fileType: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('filetype'),
|
||||||
},
|
},
|
||||||
fileExt: {
|
fileExt: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('fileext'),
|
||||||
},
|
},
|
||||||
url: {
|
url: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('url'),
|
||||||
},
|
},
|
||||||
autoTrigger: {
|
hasTrigger: {
|
||||||
default: false,
|
default: false,
|
||||||
|
parseHTML: (element) => getDatasetAttribute('hastrigger')(element) === 'true',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('error'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { BannerWrapper } from '../components/banner';
|
import { BannerWrapper } from '../components/banner';
|
||||||
import { typesAvailable } from '../services/markdown/markdownBanner';
|
import { typesAvailable } from '../services/markdown/markdownToHTML/markdownBanner';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -17,24 +18,15 @@ export const Banner = Node.create({
|
||||||
group: 'block',
|
group: 'block',
|
||||||
defining: true,
|
defining: true,
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
types: typesAvailable,
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'banner',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
type: {
|
type: {
|
||||||
default: 'info',
|
default: 'info',
|
||||||
rendered: false,
|
rendered: false,
|
||||||
parseHTML: (element) => element.getAttribute('data-banner'),
|
parseHTML: getDatasetAttribute('info'),
|
||||||
renderHTML: (attributes) => {
|
renderHTML: (attributes) => {
|
||||||
return {
|
return {
|
||||||
'data-banner': attributes.type,
|
'data-type': attributes.type,
|
||||||
'class': `banner banner-${attributes.type}`,
|
'class': `banner banner-${attributes.type}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -42,6 +34,14 @@ export const Banner = Node.create({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'banner',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -50,16 +50,8 @@ export const Banner = Node.create({
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ node, HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const { class: classy } = this.options.HTMLAttributes;
|
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||||
|
|
||||||
const attributes = {
|
|
||||||
...this.options.HTMLAttributes,
|
|
||||||
'data-callout': node.attrs.type,
|
|
||||||
'class': `${classy} ${classy}-${node.attrs.type}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return ['div', mergeAttributes(attributes, HTMLAttributes), 0];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote';
|
import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote';
|
||||||
import { wrappingInputRule } from '@tiptap/core';
|
import { wrappingInputRule } from '@tiptap/core';
|
||||||
import { getParents } from '../services/dom';
|
import { getParents } from '../services/dom';
|
||||||
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
|
import { getMarkdownSource } from '../services/markdown';
|
||||||
|
|
||||||
export const Blockquote = BuiltInBlockquote.extend({
|
export const Blockquote = BuiltInBlockquote.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list';
|
import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list';
|
||||||
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
|
import { getMarkdownSource } from '../services/markdown';
|
||||||
import { listInputRule } from '../services/listInputRule';
|
import { listInputRule } from '../services/listInputRule';
|
||||||
|
|
||||||
export const BulletList = BuiltInBulletList.extend({
|
export const BulletList = BuiltInBulletList.extend({
|
||||||
|
|
|
@ -1,37 +1,324 @@
|
||||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
|
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
|
||||||
import { lowlight } from 'lowlight/lib/all';
|
import { lowlight } from 'lowlight/lib/all';
|
||||||
|
import { Node, textblockTypeInputRule, mergeAttributes } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import { LowlightPlugin } from '../services/lowlightPlugin';
|
||||||
import { CodeBlockWrapper } from '../components/codeBlock';
|
import { CodeBlockWrapper } from '../components/codeBlock';
|
||||||
|
|
||||||
const extractLanguage = (element) => element.getAttribute('lang');
|
export interface CodeBlockOptions {
|
||||||
|
/**
|
||||||
|
* Adds a prefix to language classes that are applied to code tags.
|
||||||
|
* Defaults to `'language-'`.
|
||||||
|
*/
|
||||||
|
languageClassPrefix: string;
|
||||||
|
/**
|
||||||
|
* Define whether the node should be exited on triple enter.
|
||||||
|
* Defaults to `true`.
|
||||||
|
*/
|
||||||
|
exitOnTripleEnter: boolean;
|
||||||
|
/**
|
||||||
|
* Define whether the node should be exited on arrow down if there is no node after it.
|
||||||
|
* Defaults to `true`.
|
||||||
|
*/
|
||||||
|
exitOnArrowDown: boolean;
|
||||||
|
/**
|
||||||
|
* Custom HTML attributes that should be added to the rendered HTML tag.
|
||||||
|
*/
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export const CodeBlock = CodeBlockLowlight.extend({
|
declare module '@tiptap/core' {
|
||||||
isolating: true,
|
interface Commands<ReturnType> {
|
||||||
|
codeBlock: {
|
||||||
|
/**
|
||||||
|
* Set a code block
|
||||||
|
*/
|
||||||
|
setCodeBlock: (attributes?: { language: string }) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Toggle a code block
|
||||||
|
*/
|
||||||
|
toggleCodeBlock: (attributes?: { language: string }) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
|
||||||
|
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
|
||||||
|
|
||||||
|
export const BuiltInCodeBlock = Node.create<CodeBlockOptions>({
|
||||||
|
name: 'codeBlock',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
languageClassPrefix: 'language-',
|
||||||
|
exitOnTripleEnter: true,
|
||||||
|
exitOnArrowDown: true,
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
content: 'text*',
|
||||||
|
|
||||||
|
marks: '',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
code: true,
|
||||||
|
|
||||||
|
defining: true,
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
language: {
|
language: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => extractLanguage(element),
|
parseHTML: (element) => {
|
||||||
},
|
const { languageClassPrefix } = this.options;
|
||||||
class: {
|
const classNames = Array.from(element.firstElementChild?.classList || element.classList || []);
|
||||||
default: 'code highlight',
|
const languages = classNames
|
||||||
|
.filter((className) => className.startsWith(languageClassPrefix))
|
||||||
|
.map((className) => className.replace(languageClassPrefix, ''));
|
||||||
|
const language = languages[0];
|
||||||
|
|
||||||
|
if (!language) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return language;
|
||||||
|
},
|
||||||
|
rendered: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
'pre',
|
|
||||||
{
|
{
|
||||||
...HTMLAttributes,
|
tag: 'pre',
|
||||||
class: `content-editor-code-block ${HTMLAttributes.class}`,
|
preserveWhitespace: 'full',
|
||||||
},
|
},
|
||||||
['code', {}, 0],
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'pre',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
|
[
|
||||||
|
'code',
|
||||||
|
{
|
||||||
|
class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setCodeBlock:
|
||||||
|
(attributes) =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.setNode(this.name, attributes);
|
||||||
|
},
|
||||||
|
toggleCodeBlock:
|
||||||
|
(attributes) =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock(),
|
||||||
|
|
||||||
|
// remove code block when at start of document or code block is empty
|
||||||
|
'Backspace': () => {
|
||||||
|
const { empty, $anchor } = this.editor.state.selection;
|
||||||
|
const isAtStart = $anchor.pos === 1;
|
||||||
|
|
||||||
|
if (!empty || $anchor.parent.type.name !== this.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAtStart || !$anchor.parent.textContent.length) {
|
||||||
|
return this.editor.commands.clearNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// exit node on triple enter
|
||||||
|
'Enter': ({ editor }) => {
|
||||||
|
if (!this.options.exitOnTripleEnter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
const { $from, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $from.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||||
|
const endsWithDoubleNewline = $from.parent.textContent.endsWith('\n\n');
|
||||||
|
|
||||||
|
if (!isAtEnd || !endsWithDoubleNewline) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) => {
|
||||||
|
tr.delete($from.pos - 2, $from.pos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.exitCode()
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
|
||||||
|
// exit node on arrow down
|
||||||
|
'ArrowDown': ({ editor }) => {
|
||||||
|
if (!this.options.exitOnArrowDown) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection, doc } = state;
|
||||||
|
const { $from, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $from.parent.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||||
|
|
||||||
|
if (!isAtEnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = $from.after();
|
||||||
|
|
||||||
|
if (after === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeAfter = doc.nodeAt(after);
|
||||||
|
|
||||||
|
if (nodeAfter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.commands.exitCode();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
textblockTypeInputRule({
|
||||||
|
find: backtickInputRegex,
|
||||||
|
type: this.type,
|
||||||
|
getAttributes: (match) => ({
|
||||||
|
language: match[1],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
textblockTypeInputRule({
|
||||||
|
find: tildeInputRegex,
|
||||||
|
type: this.type,
|
||||||
|
getAttributes: (match) => ({
|
||||||
|
language: match[1],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
// this plugin creates a code block for pasted content from VS Code
|
||||||
|
// we can also detect the copied code language
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('codeBlockVSCodeHandler'),
|
||||||
|
props: {
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
if (!event.clipboardData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don’t create a new code block within code blocks
|
||||||
|
if (this.editor.isActive(this.type.name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = event.clipboardData.getData('text/plain');
|
||||||
|
const vscode = event.clipboardData.getData('vscode-editor-data');
|
||||||
|
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||||
|
const language = vscodeData?.mode;
|
||||||
|
|
||||||
|
if (!text || !language) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tr } = view.state;
|
||||||
|
|
||||||
|
// create an empty code block
|
||||||
|
tr.replaceSelectionWith(this.type.create({ language }));
|
||||||
|
|
||||||
|
// put cursor inside the newly created code block
|
||||||
|
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 2))));
|
||||||
|
|
||||||
|
// add text to code block
|
||||||
|
// strip carriage return chars from text pasted as code
|
||||||
|
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
|
||||||
|
tr.insertText(text.replace(/\r\n?/g, '\n'));
|
||||||
|
|
||||||
|
// store meta information
|
||||||
|
// this is useful for other plugins that depends on the paste event
|
||||||
|
// like the paste rule plugin
|
||||||
|
tr.setMeta('paste', true);
|
||||||
|
|
||||||
|
view.dispatch(tr);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||||
|
lowlight: any;
|
||||||
|
defaultLanguage: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeBlock = BuiltInCodeBlock.extend<CodeBlockLowlightOptions>({
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
...(this.parent?.() || []),
|
||||||
|
LowlightPlugin({
|
||||||
|
name: this.name,
|
||||||
|
lowlight: this.options.lowlight,
|
||||||
|
defaultLanguage: this.options.defaultLanguage,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(CodeBlockWrapper);
|
return ReactNodeViewRenderer(CodeBlockWrapper);
|
||||||
},
|
},
|
||||||
}).configure({
|
}).configure({
|
||||||
lowlight,
|
lowlight,
|
||||||
|
defaultLanguage: 'auto',
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { DocumentChildrenWrapper } from '../components/documentChildren';
|
import { DocumentChildrenWrapper } from '../components/documentChildren';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -21,23 +22,35 @@ export const DocumentChildren = Node.create({
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
color: {
|
wikiId: {
|
||||||
default: 'grey',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
default: '',
|
default: '',
|
||||||
|
parseHTML: getDatasetAttribute('wikiId'),
|
||||||
|
},
|
||||||
|
documentId: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: getDatasetAttribute('documentId'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'documentChildren',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: 'div[data-type=documentChildren]' }];
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { DocumentReferenceWrapper } from '../components/documentReference';
|
import { DocumentReferenceWrapper } from '../components/documentReference';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -23,22 +24,37 @@ export const DocumentReference = Node.create({
|
||||||
return {
|
return {
|
||||||
wikiId: {
|
wikiId: {
|
||||||
default: '',
|
default: '',
|
||||||
|
parseHTML: getDatasetAttribute('wikiId'),
|
||||||
},
|
},
|
||||||
documentId: {
|
documentId: {
|
||||||
default: '',
|
default: '',
|
||||||
|
parseHTML: getDatasetAttribute('documentId'),
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: '',
|
default: '',
|
||||||
|
parseHTML: getDatasetAttribute('title'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'documentReference',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: 'div[data-type=documentReference]' }];
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
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];
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,37 +0,0 @@
|
||||||
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];
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,19 +0,0 @@
|
||||||
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];
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -19,18 +19,16 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {},
|
||||||
class: 'hr-line',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: 'div[class=hr-line]' }];
|
return [{ tag: 'hr' }];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
return ['hr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
|
|
|
@ -2,24 +2,7 @@ import { Mark, mergeAttributes, markInputRule } from '@tiptap/core';
|
||||||
import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
|
import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
|
||||||
import { markInputRegex, extractMarkAttributesFromMatch } from '../services/markUtils';
|
import { markInputRegex, extractMarkAttributesFromMatch } from '../services/markUtils';
|
||||||
|
|
||||||
const marks = [
|
export const marks = [{ name: 'underline', tag: 'u' }];
|
||||||
'ins',
|
|
||||||
'abbr',
|
|
||||||
'bdo',
|
|
||||||
'cite',
|
|
||||||
'dfn',
|
|
||||||
'mark',
|
|
||||||
'small',
|
|
||||||
'span',
|
|
||||||
'time',
|
|
||||||
'kbd',
|
|
||||||
'q',
|
|
||||||
'samp',
|
|
||||||
'var',
|
|
||||||
'ruby',
|
|
||||||
'rp',
|
|
||||||
'rt',
|
|
||||||
];
|
|
||||||
|
|
||||||
const attrs = {
|
const attrs = {
|
||||||
time: ['datetime'],
|
time: ['datetime'],
|
||||||
|
@ -28,9 +11,10 @@ const attrs = {
|
||||||
bdo: ['dir'],
|
bdo: ['dir'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HTMLMarks = marks.map((name) =>
|
export const HTMLMarks = marks.slice(1).map(({ name, tag }) =>
|
||||||
Mark.create({
|
Mark.create({
|
||||||
name,
|
name,
|
||||||
|
tag,
|
||||||
inclusive: false,
|
inclusive: false,
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
|
@ -51,17 +35,17 @@ export const HTMLMarks = marks.map((name) =>
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }];
|
return [{ tag: tag, priority: PARSE_HTML_PRIORITY_LOWEST }];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
return [tag, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||||
},
|
},
|
||||||
|
|
||||||
addInputRules() {
|
addInputRules() {
|
||||||
return [
|
return [
|
||||||
markInputRule({
|
markInputRule({
|
||||||
find: markInputRegex(name),
|
find: markInputRegex(tag),
|
||||||
type: this.type,
|
type: this.type,
|
||||||
getAttributes: extractMarkAttributesFromMatch,
|
getAttributes: extractMarkAttributesFromMatch,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { IframeWrapper } from '../components/iframe';
|
import { IframeWrapper } from '../components/iframe';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -11,7 +12,7 @@ declare module '@tiptap/core' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Iframe = Node.create({
|
export const Iframe = Node.create({
|
||||||
name: 'external-iframe',
|
name: 'iframe',
|
||||||
content: '',
|
content: '',
|
||||||
marks: '',
|
marks: '',
|
||||||
group: 'block',
|
group: 'block',
|
||||||
|
@ -21,7 +22,7 @@ export const Iframe = Node.create({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
'data-type': 'external-iframe',
|
class: 'iframe',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -30,12 +31,15 @@ export const Iframe = Node.create({
|
||||||
return {
|
return {
|
||||||
width: {
|
width: {
|
||||||
default: '100%',
|
default: '100%',
|
||||||
|
parseHTML: getDatasetAttribute('width'),
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
default: 54,
|
default: 200,
|
||||||
|
parseHTML: getDatasetAttribute('height'),
|
||||||
},
|
},
|
||||||
url: {
|
url: {
|
||||||
default: null,
|
default: null,
|
||||||
|
parseHTML: getDatasetAttribute('url'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -43,7 +47,7 @@ export const Iframe = Node.create({
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'iframe[data-type="external-iframe"]',
|
tag: 'iframe',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
@ -51,7 +51,7 @@ export const Image = BuiltInImage.extend({
|
||||||
height: {
|
height: {
|
||||||
default: 'auto',
|
default: 'auto',
|
||||||
},
|
},
|
||||||
autoTrigger: {
|
hasTrigger: {
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
|
@ -14,26 +14,36 @@ export const KatexInputRegex = /^\$\$(.+)?\$\$$/;
|
||||||
|
|
||||||
export const Katex = Node.create({
|
export const Katex = Node.create({
|
||||||
name: 'katex',
|
name: 'katex',
|
||||||
group: 'block',
|
group: 'inline',
|
||||||
defining: true,
|
inline: true,
|
||||||
draggable: true,
|
|
||||||
selectable: true,
|
selectable: true,
|
||||||
atom: true,
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'katex',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
text: {
|
text: {
|
||||||
default: '',
|
default: '',
|
||||||
|
parseHTML: (element) => {
|
||||||
|
return element.getAttribute('data-text');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: 'div[data-type=katex]' }];
|
return [{ tag: 'span.katex' }];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
|
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import { safeJSONParse } from 'helpers/json';
|
||||||
import { MindWrapper } from '../components/mind';
|
import { MindWrapper } from '../components/mind';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
const DEFAULT_MIND_DATA = {
|
const DEFAULT_MIND_DATA = {
|
||||||
meta: {
|
meta: {
|
||||||
|
@ -21,31 +23,34 @@ declare module '@tiptap/core' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Mind = Node.create({
|
export const Mind = Node.create({
|
||||||
name: 'jsmind',
|
name: 'mind',
|
||||||
content: '',
|
content: '',
|
||||||
marks: '',
|
marks: '',
|
||||||
group: 'block',
|
group: 'block',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
atom: true,
|
atom: true,
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
HTMLAttributes: {
|
|
||||||
'data-type': 'jsmind',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
width: {
|
width: {
|
||||||
default: '100%',
|
default: '100%',
|
||||||
|
parseHTML: getDatasetAttribute('width'),
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
default: 240,
|
default: 240,
|
||||||
|
parseHTML: getDatasetAttribute('height'),
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
default: DEFAULT_MIND_DATA,
|
default: DEFAULT_MIND_DATA,
|
||||||
|
parseHTML: getDatasetAttribute('data', true),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'mind',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -53,7 +58,7 @@ export const Mind = Node.create({
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'div[data-type="jsmind"]',
|
tag: 'div',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list';
|
import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list';
|
||||||
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
|
import { getMarkdownSource } from '../services/markdown';
|
||||||
|
|
||||||
export const OrderedList = BuiltInOrderedList.extend({
|
export const OrderedList = BuiltInOrderedList.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import { Extension } from '@tiptap/core';
|
import { Extension } from '@tiptap/core';
|
||||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||||
import { markdownSerializer } from '../services/markdown';
|
|
||||||
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
|
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
|
||||||
import { handleFileEvent } from '../services/upload';
|
import { handleFileEvent } from '../services/upload';
|
||||||
import { isInCode, LANGUAGES } from '../services/code';
|
import { isInCode, LANGUAGES } from '../services/code';
|
||||||
import { isMarkdown, normalizePastedMarkdown } from '../services/markdown/helpers';
|
import {
|
||||||
|
isMarkdown,
|
||||||
|
normalizePastedMarkdown,
|
||||||
|
markdownToProsemirror,
|
||||||
|
prosemirrorToMarkdown,
|
||||||
|
} from '../services/markdown';
|
||||||
|
import { isTitleNode } from '../services/node';
|
||||||
|
|
||||||
export const Paste = Extension.create({
|
export const Paste = Extension.create({
|
||||||
name: 'paste',
|
name: 'paste',
|
||||||
|
@ -39,7 +44,7 @@ export const Paste = Extension.create({
|
||||||
// 粘贴代码
|
// 粘贴代码
|
||||||
if (isInCode(view.state)) {
|
if (isInCode(view.state)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
view.dispatch(view.state.tr.insertText(text));
|
view.dispatch(view.state.tr.insertText(text).scrollIntoView());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,23 +60,29 @@ export const Paste = Extension.create({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
view.dispatch(view.state.tr.insertText(text));
|
view.dispatch(view.state.tr.insertText(text).scrollIntoView());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 markdown
|
// 处理 markdown
|
||||||
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在
|
const firstNode = view.props.state.doc.content.firstChild;
|
||||||
// const hasTitle = isTitleNode(view.props.state.doc.content.firstChild);
|
const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0;
|
||||||
let schema = view.props.state.schema;
|
const schema = view.props.state.schema;
|
||||||
const doc = markdownSerializer.deserialize({
|
const doc = markdownToProsemirror({
|
||||||
schema,
|
schema,
|
||||||
content: normalizePastedMarkdown(text),
|
content: normalizePastedMarkdown(text),
|
||||||
|
hasTitle,
|
||||||
});
|
});
|
||||||
// @ts-ignore
|
let tr = view.state.tr;
|
||||||
const transaction = view.state.tr.insert(view.state.selection.head, doc);
|
const selection = tr.selection;
|
||||||
view.dispatch(transaction);
|
view.state.doc.nodesBetween(selection.from, selection.to, (node, position) => {
|
||||||
|
const startPosition = hasTitle ? Math.min(position, selection.from) : 0;
|
||||||
|
const endPosition = Math.min(position + node.nodeSize, selection.to);
|
||||||
|
tr = tr.replaceWith(startPosition, endPosition, view.state.schema.nodeFromJSON(doc));
|
||||||
|
});
|
||||||
|
view.dispatch(tr.scrollIntoView());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,12 +116,13 @@ export const Paste = Extension.create({
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
clipboardTextSerializer: (slice) => {
|
clipboardTextSerializer: (slice) => {
|
||||||
const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content);
|
const doc = slice.content;
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const content = markdownSerializer.serialize({
|
|
||||||
schema: this.editor.schema,
|
const content = prosemirrorToMarkdown({
|
||||||
content: doc,
|
content: doc,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { StatusWrapper } from '../components/status';
|
import { StatusWrapper } from '../components/status';
|
||||||
|
import { getDatasetAttribute } from '../services/dataset';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -20,19 +21,33 @@ export const Status = Node.create({
|
||||||
return {
|
return {
|
||||||
color: {
|
color: {
|
||||||
default: 'grey',
|
default: 'grey',
|
||||||
|
parseHTML: getDatasetAttribute('color'),
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
default: '',
|
default: '',
|
||||||
|
parseHTML: getDatasetAttribute('text'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'status',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: 'span[data-type=status]' }];
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,5 +1,56 @@
|
||||||
|
import { mergeAttributes } from '@tiptap/core';
|
||||||
import { Table as BuiltInTable } from '@tiptap/extension-table';
|
import { Table as BuiltInTable } from '@tiptap/extension-table';
|
||||||
|
|
||||||
export const Table = BuiltInTable.configure({
|
export const Table = BuiltInTable.extend({
|
||||||
resizable: false,
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
let totalWidth = 0;
|
||||||
|
let fixedWidth = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// use first row to determine width of table;
|
||||||
|
// @ts-ignore
|
||||||
|
const tr = node.content.content[0];
|
||||||
|
tr.content.content.forEach((td) => {
|
||||||
|
if (td.attrs.colwidth) {
|
||||||
|
td.attrs.colwidth.forEach((col) => {
|
||||||
|
if (!col) {
|
||||||
|
fixedWidth = false;
|
||||||
|
totalWidth += this.options.cellMinWidth;
|
||||||
|
} else {
|
||||||
|
totalWidth += col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fixedWidth = false;
|
||||||
|
const colspan = td.attrs.colspan ? td.attrs.colspan : 1;
|
||||||
|
totalWidth += this.options.cellMinWidth * colspan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fixedWidth = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedWidth && totalWidth > 0) {
|
||||||
|
HTMLAttributes.style = `width: ${totalWidth}px;`;
|
||||||
|
} else if (totalWidth && totalWidth > 0) {
|
||||||
|
HTMLAttributes.style = `min-width: 100%`;
|
||||||
|
} else {
|
||||||
|
HTMLAttributes.style = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
{ class: 'tableWrapper' },
|
||||||
|
['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
|
resizable: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
||||||
|
import { mergeAttributes } from '@tiptap/core';
|
||||||
import { TableCell as BuiltInTableCell } from '@tiptap/extension-table-cell';
|
import { TableCell as BuiltInTableCell } from '@tiptap/extension-table-cell';
|
||||||
import { Tooltip } from 'components/tooltip';
|
import { Tooltip } from 'components/tooltip';
|
||||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||||
|
@ -16,6 +17,56 @@ import {
|
||||||
import { FloatMenuView } from '../views/floatMenuView';
|
import { FloatMenuView } from '../views/floatMenuView';
|
||||||
|
|
||||||
export const TableCell = BuiltInTableCell.extend({
|
export const TableCell = BuiltInTableCell.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
colspan: {
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
rowspan: {
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
colwidth: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colwidth = element.getAttribute('colwidth');
|
||||||
|
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
let totalWidth = 0;
|
||||||
|
let fixedWidth = true;
|
||||||
|
|
||||||
|
if (HTMLAttributes.colwidth) {
|
||||||
|
HTMLAttributes.colwidth.forEach((col) => {
|
||||||
|
if (!col) {
|
||||||
|
fixedWidth = false;
|
||||||
|
} else {
|
||||||
|
totalWidth += col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fixedWidth = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedWidth && totalWidth > 0) {
|
||||||
|
HTMLAttributes.style = `width: ${totalWidth}px;`;
|
||||||
|
} else if (totalWidth && totalWidth > 0) {
|
||||||
|
HTMLAttributes.style = `min-width: ${totalWidth}px`;
|
||||||
|
} else {
|
||||||
|
HTMLAttributes.style = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||||
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const extensionThis = this;
|
const extensionThis = this;
|
||||||
let selectedRowIndex = -1;
|
let selectedRowIndex = -1;
|
||||||
|
@ -27,7 +78,7 @@ export const TableCell = BuiltInTableCell.extend({
|
||||||
new FloatMenuView({
|
new FloatMenuView({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
zIndex: 10000,
|
zIndex: 100,
|
||||||
offset: [-28, 0],
|
offset: [-28, 0],
|
||||||
},
|
},
|
||||||
shouldShow: ({ editor }, floatMenuView) => {
|
shouldShow: ({ editor }, floatMenuView) => {
|
||||||
|
|
|
@ -8,7 +8,62 @@ import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||||
import { getCellsInRow, isColumnSelected, isTableSelected, selectColumn } from '../services/table';
|
import { getCellsInRow, isColumnSelected, isTableSelected, selectColumn } from '../services/table';
|
||||||
import { FloatMenuView } from '../views/floatMenuView';
|
import { FloatMenuView } from '../views/floatMenuView';
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
import { mergeAttributes } from '@tiptap/core';
|
||||||
|
// import TableHeader from "@tiptap/extension-table-header";
|
||||||
|
|
||||||
export const TableHeader = BuiltInTableHeader.extend({
|
export const TableHeader = BuiltInTableHeader.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
colspan: {
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
rowspan: {
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
colwidth: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colwidth = element.getAttribute('colwidth');
|
||||||
|
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
let totalWidth = 0;
|
||||||
|
let fixedWidth = true;
|
||||||
|
|
||||||
|
if (HTMLAttributes.colwidth) {
|
||||||
|
HTMLAttributes.colwidth.forEach((col) => {
|
||||||
|
if (!col) {
|
||||||
|
fixedWidth = false;
|
||||||
|
} else {
|
||||||
|
totalWidth += col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fixedWidth = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedWidth && totalWidth > 0) {
|
||||||
|
HTMLAttributes.style = `width: ${totalWidth}px;`;
|
||||||
|
} else if (totalWidth && totalWidth > 0) {
|
||||||
|
HTMLAttributes.style = `min-width: ${totalWidth}px`;
|
||||||
|
} else {
|
||||||
|
HTMLAttributes.style = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||||
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const extensionThis = this;
|
const extensionThis = this;
|
||||||
|
|
||||||
|
@ -19,9 +74,9 @@ export const TableHeader = BuiltInTableHeader.extend({
|
||||||
new FloatMenuView({
|
new FloatMenuView({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
zIndex: 10000,
|
zIndex: 100,
|
||||||
},
|
},
|
||||||
shouldShow: ({ editor }) => {
|
shouldShow: ({ editor }, floatMenuView) => {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -30,6 +85,12 @@ export const TableHeader = BuiltInTableHeader.extend({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const cells = getCellsInRow(0)(selection);
|
const cells = getCellsInRow(0)(selection);
|
||||||
|
|
||||||
|
if (cells && cells[0]) {
|
||||||
|
const node = editor.view.nodeDOM(cells[0].pos) as HTMLElement;
|
||||||
|
floatMenuView.setConatiner(node.parentElement.parentElement.parentElement.parentElement);
|
||||||
|
}
|
||||||
|
|
||||||
return !!cells?.some((cell, index) => isColumnSelected(index)(selection));
|
return !!cells?.some((cell, index) => isColumnSelected(index)(selection));
|
||||||
},
|
},
|
||||||
init: (dom, editor) => {
|
init: (dom, editor) => {
|
||||||
|
|
|
@ -1,34 +1,12 @@
|
||||||
import { wrappingInputRule } from '@tiptap/core';
|
import { wrappingInputRule } from '@tiptap/core';
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
|
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
|
||||||
import { Plugin } from 'prosemirror-state';
|
import { Plugin } from 'prosemirror-state';
|
||||||
import { findParentNodeClosestToPos } from 'prosemirror-utils';
|
import { findParentNodeClosestToPos } from 'prosemirror-utils';
|
||||||
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
|
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
|
||||||
|
import { TaskItemWrapper } from '../components/taskItem';
|
||||||
|
|
||||||
const CustomTaskItem = BuiltInTaskItem.extend({
|
const CustomTaskItem = 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() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -51,6 +29,44 @@ const CustomTaskItem = BuiltInTaskItem.extend({
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// addNodeView() {
|
||||||
|
// return ReactNodeViewRenderer(TaskItemWrapper);
|
||||||
|
// },
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, HTMLAttributes, getPos, editor }) => {
|
||||||
|
const listItem = document.createElement('li');
|
||||||
|
const checkboxWrapper = document.createElement('span');
|
||||||
|
const content = document.createElement('div');
|
||||||
|
|
||||||
|
checkboxWrapper.contentEditable = 'false';
|
||||||
|
|
||||||
|
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
|
||||||
|
listItem.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
listItem.dataset.checked = node.attrs.checked;
|
||||||
|
listItem.append(checkboxWrapper, content);
|
||||||
|
|
||||||
|
Object.entries(HTMLAttributes).forEach(([key, value]) => {
|
||||||
|
listItem.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom: listItem,
|
||||||
|
contentDOM: content,
|
||||||
|
update: (updatedNode) => {
|
||||||
|
if (updatedNode.type !== this.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
listItem.dataset.checked = updatedNode.attrs.checked;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
|
@ -65,14 +81,20 @@ const CustomTaskItem = BuiltInTaskItem.extend({
|
||||||
const parentList = findParentNodeClosestToPos(position, function (node) {
|
const parentList = findParentNodeClosestToPos(position, function (node) {
|
||||||
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem;
|
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem;
|
||||||
});
|
});
|
||||||
// @ts-ignore
|
if (!parentList) {
|
||||||
const isListClicked = event.target.tagName.toLowerCase() === 'li';
|
|
||||||
if (!isListClicked || !parentList || parentList.node.type !== schema.nodes.taskItem) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const element = view.nodeDOM(parentList.pos) as HTMLLIElement;
|
||||||
|
if (element.tagName.toLowerCase() !== 'li') return;
|
||||||
|
|
||||||
|
const parentElement = element.parentElement;
|
||||||
|
const type = parentElement && parentElement.getAttribute('data-type');
|
||||||
|
if (!type || type.toLowerCase() !== 'tasklist') return;
|
||||||
|
|
||||||
const tr = state.tr;
|
const tr = state.tr;
|
||||||
|
const nextValue = !(element.getAttribute('data-checked') === 'true');
|
||||||
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
|
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
|
||||||
checked: !parentList.node.attrs.checked,
|
checked: nextValue,
|
||||||
});
|
});
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { mergeAttributes } from '@tiptap/core';
|
|
||||||
import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list';
|
import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list';
|
||||||
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
|
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
|
||||||
|
|
||||||
|
@ -11,8 +10,4 @@ export const TaskList = BuiltInTaskList.extend({
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
|
|
||||||
return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,23 @@
|
||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
|
||||||
export const Title = Node.create({
|
export interface TitleOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
title: {
|
||||||
|
setTitle: (attributes) => ReturnType;
|
||||||
|
toggleTitle: (attributes) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Title = Node.create<TitleOptions>({
|
||||||
name: 'title',
|
name: 'title',
|
||||||
content: 'text*',
|
content: 'inline*',
|
||||||
selectable: true,
|
group: 'block',
|
||||||
defining: true,
|
defining: true,
|
||||||
inline: false,
|
|
||||||
group: 'basic',
|
|
||||||
allowGapCursor: true,
|
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
|
@ -16,16 +26,15 @@ export const Title = Node.create({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'h1[class=title]',
|
tag: 'p[class=title]',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
|
import { Space, Button } from '@douyinfe/semi-ui';
|
||||||
import { IconUndo, IconRedo } from '@douyinfe/semi-icons';
|
import { IconUndo, IconRedo } from '@douyinfe/semi-icons';
|
||||||
|
import { Tooltip } from 'components/tooltip';
|
||||||
import { IconClear } from 'components/icons';
|
import { IconClear } from 'components/icons';
|
||||||
import { Divider } from './components/divider';
|
import { Divider } from './components/divider';
|
||||||
import { MediaInsertMenu } from './menus/mediaInsert';
|
import { MediaInsertMenu } from './menus/mediaInsert';
|
||||||
|
|
|
@ -6,10 +6,13 @@ import { Link } from '../extensions/link';
|
||||||
import { Attachment } from '../extensions/attachment';
|
import { Attachment } from '../extensions/attachment';
|
||||||
import { Image } from '../extensions/image';
|
import { Image } from '../extensions/image';
|
||||||
import { Banner } from '../extensions/banner';
|
import { Banner } from '../extensions/banner';
|
||||||
|
import { Status } from '../extensions/status';
|
||||||
import { HorizontalRule } from '../extensions/horizontalRule';
|
import { HorizontalRule } from '../extensions/horizontalRule';
|
||||||
import { Iframe } from '../extensions/iframe';
|
import { Iframe } from '../extensions/iframe';
|
||||||
import { Mind } from '../extensions/mind';
|
import { Mind } from '../extensions/mind';
|
||||||
import { Table } from '../extensions/table';
|
import { Table } from '../extensions/table';
|
||||||
|
import { TaskList } from '../extensions/taskList';
|
||||||
|
import { TaskItem } from '../extensions/taskItem';
|
||||||
import { Katex } from '../extensions/katex';
|
import { Katex } from '../extensions/katex';
|
||||||
import { DocumentReference } from '../extensions/documentReference';
|
import { DocumentReference } from '../extensions/documentReference';
|
||||||
import { DocumentChildren } from '../extensions/documentChildren';
|
import { DocumentChildren } from '../extensions/documentChildren';
|
||||||
|
@ -21,9 +24,12 @@ const OTHER_BUBBLE_MENU_TYPES = [
|
||||||
Attachment.name,
|
Attachment.name,
|
||||||
Image.name,
|
Image.name,
|
||||||
Banner.name,
|
Banner.name,
|
||||||
|
Status.name,
|
||||||
Iframe.name,
|
Iframe.name,
|
||||||
Mind.name,
|
Mind.name,
|
||||||
Table.name,
|
Table.name,
|
||||||
|
TaskList.name,
|
||||||
|
TaskItem.name,
|
||||||
DocumentReference.name,
|
DocumentReference.name,
|
||||||
DocumentChildren.name,
|
DocumentChildren.name,
|
||||||
Katex.name,
|
Katex.name,
|
||||||
|
|
|
@ -84,7 +84,7 @@ export class BubbleMenuView {
|
||||||
this.view.dom.addEventListener('dragstart', this.dragstartHandler);
|
this.view.dom.addEventListener('dragstart', this.dragstartHandler);
|
||||||
this.editor.on('focus', this.focusHandler);
|
this.editor.on('focus', this.focusHandler);
|
||||||
this.editor.on('blur', this.blurHandler);
|
this.editor.on('blur', this.blurHandler);
|
||||||
this.tippyOptions = tippyOptions;
|
this.tippyOptions = tippyOptions || {};
|
||||||
// Detaches menu content from its current parent
|
// Detaches menu content from its current parent
|
||||||
this.element.remove();
|
this.element.remove();
|
||||||
this.element.style.visibility = 'visible';
|
this.element.style.visibility = 'visible';
|
||||||
|
@ -133,7 +133,7 @@ export class BubbleMenuView {
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
hideOnClick: 'toggle',
|
hideOnClick: 'toggle',
|
||||||
...this.tippyOptions,
|
...Object.assign({ zIndex: 99 }, this.tippyOptions),
|
||||||
});
|
});
|
||||||
|
|
||||||
// maybe we have to hide tippy on its own blur event as well
|
// maybe we have to hide tippy on its own blur event as well
|
||||||
|
|
|
@ -21,6 +21,8 @@ export const Paragraph = ({ editor }) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// console.log(getCurrentCaretTitle(editor));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
disabled={isTitleActive(editor)}
|
disabled={isTitleActive(editor)}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core';
|
||||||
import { Button, Dropdown, Popover } from '@douyinfe/semi-ui';
|
import { Button, Dropdown, Popover } from '@douyinfe/semi-ui';
|
||||||
import { IconPlus } from '@douyinfe/semi-icons';
|
import { IconPlus } from '@douyinfe/semi-icons';
|
||||||
import { Tooltip } from 'components/tooltip';
|
import { Tooltip } from 'components/tooltip';
|
||||||
import { Upload } from './components/upload';
|
|
||||||
import {
|
import {
|
||||||
IconDocument,
|
IconDocument,
|
||||||
IconMind,
|
IconMind,
|
||||||
|
@ -18,7 +17,6 @@ import {
|
||||||
} from 'components/icons';
|
} from 'components/icons';
|
||||||
import { GridSelect } from 'components/grid-select';
|
import { GridSelect } from 'components/grid-select';
|
||||||
import { isTitleActive } from '../services/isActive';
|
import { isTitleActive } from '../services/isActive';
|
||||||
import { handleFileEvent } from '../services/upload';
|
|
||||||
|
|
||||||
export const MediaInsertMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
|
export const MediaInsertMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
|
|
|
@ -25,7 +25,9 @@ export const TableBubbleMenu = ({ editor }) => {
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
maxWidth: 456,
|
maxWidth: 456,
|
||||||
}}
|
}}
|
||||||
matchRenderContainer={(node: HTMLElement) => node.classList && node.tagName === 'TABLE'}
|
matchRenderContainer={(node: HTMLElement) =>
|
||||||
|
node && node.classList && node.classList.contains('tableWrapper') && node.tagName === 'DIV'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip content="向前插入一列">
|
<Tooltip content="向前插入一列">
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { safeJSONParse } from 'helpers/json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 转为字符串
|
||||||
|
* @param json
|
||||||
|
*/
|
||||||
|
export const jsonToStr = (json: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(json);
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字符串转为 JSON
|
||||||
|
* @param str
|
||||||
|
*/
|
||||||
|
export const strToJSON = (str: string) => {
|
||||||
|
return safeJSONParse(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 转为 DOM 节点的 dataset
|
||||||
|
* @param element
|
||||||
|
* @param json
|
||||||
|
*/
|
||||||
|
export const jsonToDOMDataset = (json: Record<string, unknown>) => {
|
||||||
|
return Object.keys(json).map((key) => {
|
||||||
|
let value = json[key];
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
value = JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `data-${key}`,
|
||||||
|
value: encodeURIComponent(value as string),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 element 上提取 dataset 数据
|
||||||
|
* @param element
|
||||||
|
* @param attribute
|
||||||
|
* @param transformToJSON 是否要转为 JSON
|
||||||
|
*/
|
||||||
|
export const getDatasetAttribute =
|
||||||
|
(attribute: string, transformToJSON: boolean = false) =>
|
||||||
|
(element: HTMLElement) => {
|
||||||
|
const dataKey = attribute.startsWith('data-') ? attribute : `data-${attribute}`;
|
||||||
|
const value = decodeURIComponent(element.getAttribute(dataKey));
|
||||||
|
|
||||||
|
if (transformToJSON) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('%') || value.includes('auto')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNumber = parseInt(value);
|
||||||
|
return toNumber !== toNumber ? value : toNumber; // 避免 NaN
|
||||||
|
};
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||||
|
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||||
|
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||||
|
import { findChildren } from '@tiptap/core';
|
||||||
|
|
||||||
|
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
const classes = [...className, ...(node.properties ? node.properties.className : [])];
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
return parseNodes(node.children, classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: node.value,
|
||||||
|
classes,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHighlightNodes(result: any) {
|
||||||
|
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||||
|
return result.value || result.children || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecorations({
|
||||||
|
doc,
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
}: {
|
||||||
|
doc: ProsemirrorNode;
|
||||||
|
name: string;
|
||||||
|
lowlight: any;
|
||||||
|
defaultLanguage: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
|
||||||
|
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
|
||||||
|
let from = block.pos + 1;
|
||||||
|
const language = block.node.attrs.language || defaultLanguage;
|
||||||
|
const languages = lowlight.listLanguages();
|
||||||
|
const nodes =
|
||||||
|
language && languages.includes(language)
|
||||||
|
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||||
|
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent));
|
||||||
|
|
||||||
|
parseNodes(nodes).forEach((node) => {
|
||||||
|
const to = from + node.text.length;
|
||||||
|
|
||||||
|
if (node.classes.length) {
|
||||||
|
const decoration = Decoration.inline(from, to, {
|
||||||
|
class: node.classes.join(' '),
|
||||||
|
});
|
||||||
|
|
||||||
|
decorations.push(decoration);
|
||||||
|
}
|
||||||
|
|
||||||
|
from = to;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LowlightPlugin({
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
lowlight: any;
|
||||||
|
defaultLanguage: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return new Plugin({
|
||||||
|
key: new PluginKey('lowlight'),
|
||||||
|
|
||||||
|
state: {
|
||||||
|
init: (_, { doc }) =>
|
||||||
|
getDecorations({
|
||||||
|
doc,
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
}),
|
||||||
|
apply: (transaction, decorationSet, oldState, newState) => {
|
||||||
|
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||||
|
const newNodeName = newState.selection.$head.parent.type.name;
|
||||||
|
const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
|
||||||
|
const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
|
||||||
|
|
||||||
|
if (
|
||||||
|
transaction.docChanged &&
|
||||||
|
// Apply decorations if:
|
||||||
|
// selection includes named node,
|
||||||
|
([oldNodeName, newNodeName].includes(name) ||
|
||||||
|
// OR transaction adds/removes named node,
|
||||||
|
newNodes.length !== oldNodes.length ||
|
||||||
|
// OR transaction has changes that completely encapsulte a node
|
||||||
|
// (for example, a transaction that affects the entire document).
|
||||||
|
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||||
|
transaction.steps.some((step) => {
|
||||||
|
// @ts-ignore
|
||||||
|
return (
|
||||||
|
step.from !== undefined &&
|
||||||
|
// @ts-ignore
|
||||||
|
step.to !== undefined &&
|
||||||
|
oldNodes.some((node) => {
|
||||||
|
// @ts-ignore
|
||||||
|
return (
|
||||||
|
node.pos >= step.from &&
|
||||||
|
// @ts-ignore
|
||||||
|
node.pos + node.node.nodeSize <= step.to
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
return getDecorations({
|
||||||
|
doc: transaction.doc,
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return this.getState(state);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,4 +1,8 @@
|
||||||
export const isMarkdown = (text: string): boolean => {
|
export const isMarkdown = (text: string): boolean => {
|
||||||
|
// html
|
||||||
|
const html = text.match(/<\/?[a-z][\s\S]*>/i);
|
||||||
|
if (html && html.length) return true;
|
||||||
|
|
||||||
// table
|
// table
|
||||||
const tables = text.match(/^\|(\S)*\|/gm);
|
const tables = text.match(/^\|(\S)*\|/gm);
|
||||||
if (tables && tables.length) return true;
|
if (tables && tables.length) return true;
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Renderer } from './renderer';
|
||||||
|
|
||||||
|
const renderer = new Renderer();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HTML 转换成 prosemirror node
|
||||||
|
* @param body
|
||||||
|
* @param forceATitle 是否需要一个标题
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const htmlToPromsemirror = (body, forceATitle = false) => {
|
||||||
|
const json = renderer.render(body);
|
||||||
|
|
||||||
|
// 设置标题
|
||||||
|
if (forceATitle) {
|
||||||
|
const firstNode = json.content[0];
|
||||||
|
if (firstNode && firstNode.type !== 'title') {
|
||||||
|
if (firstNode.type === 'heading' || firstNode.type === 'paragraph') {
|
||||||
|
firstNode.type = 'title';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = json.content;
|
||||||
|
const result = { type: 'doc', content: [] };
|
||||||
|
|
||||||
|
for (let i = 0; i < nodes.length; ) {
|
||||||
|
const node = nodes[i];
|
||||||
|
// 目的:合并成 promirror 需要的 table 格式
|
||||||
|
if (node.type === 'tableRow') {
|
||||||
|
const nextNode = nodes[i + 1];
|
||||||
|
if (nextNode && nextNode.type === 'table') {
|
||||||
|
nextNode.content.unshift(node);
|
||||||
|
result.content.push(nextNode);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.content.push(node);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trailing node
|
||||||
|
result.content.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: {
|
||||||
|
indent: 0,
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Mark } from './mark';
|
||||||
|
|
||||||
|
export class Bold extends Mark {
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'STRONG';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'bold',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Mark } from './mark';
|
||||||
|
|
||||||
|
export class Code extends Mark {
|
||||||
|
matching() {
|
||||||
|
if (this.DOMNode.parentNode.nodeName === 'PRE') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.DOMNode.nodeName === 'CODE';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'code',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Mark } from './mark';
|
||||||
|
export class Italic extends Mark {
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'EM';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'italic',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Mark } from './mark';
|
||||||
|
export class Link extends Mark {
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: this.DOMNode.getAttribute('href'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
export class Mark {
|
||||||
|
type: string;
|
||||||
|
DOMNode: HTMLElement;
|
||||||
|
|
||||||
|
constructor(DomNode) {
|
||||||
|
this.type = 'mark';
|
||||||
|
this.DOMNode = DomNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Mark } from './mark';
|
||||||
|
|
||||||
|
export class Underline extends Mark {
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'U';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'underline',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Attachment extends Node {
|
||||||
|
type = 'attachment';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('attachment');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Banner extends Node {
|
||||||
|
type = 'banner';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('banner');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Blockquote extends Node {
|
||||||
|
type = 'blockquote';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'BLOCKQUOTE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// data() {
|
||||||
|
// return {
|
||||||
|
// type: 'blockquote',
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class BulletList extends Node {
|
||||||
|
type = 'bulletList';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'UL';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class CodeBlock extends Node {
|
||||||
|
type = 'codeBlock';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'CODE' && this.DOMNode.parentNode.nodeName === 'PRE';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class CodeBlockWrapper extends Node {
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'PRE';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class DocumentChildren extends Node {
|
||||||
|
type = 'documentChildren';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('documentChildren');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class DocumentReference extends Node {
|
||||||
|
type = 'documentReference';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('documentReference');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class HardBreak extends Node {
|
||||||
|
type = 'hardBreak';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'BR';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
export class Heading extends Node {
|
||||||
|
type = 'heading';
|
||||||
|
|
||||||
|
getLevel() {
|
||||||
|
const matches = this.DOMNode.nodeName.match(/^H([1-6])/);
|
||||||
|
return matches ? matches[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return Boolean(this.getLevel());
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'heading',
|
||||||
|
attrs: {
|
||||||
|
level: this.getLevel(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class HorizontalRule extends Node {
|
||||||
|
type = 'horizontalRule';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'HR';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Iframe extends Node {
|
||||||
|
type = 'iframe';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('iframe');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Image extends Node {
|
||||||
|
type = 'image';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'IMG';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: this.DOMNode.getAttribute('src'),
|
||||||
|
class: this.DOMNode.getAttribute('class') || undefined,
|
||||||
|
alt: this.DOMNode.getAttribute('alt') || undefined,
|
||||||
|
title: this.DOMNode.getAttribute('title') || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Katex extends Node {
|
||||||
|
type = 'katex';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'SPAN' && this.DOMNode.classList.contains('katex');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class ListItem extends Node {
|
||||||
|
constructor(DomNode) {
|
||||||
|
super(DomNode);
|
||||||
|
this.wrapper = {
|
||||||
|
type: 'paragraph',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type = 'listItem';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'LI';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Mind extends Node {
|
||||||
|
type = 'mind';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('mind');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { getAttributes } from '../utils';
|
||||||
|
|
||||||
|
export class Node {
|
||||||
|
wrapper: unknown;
|
||||||
|
type = 'node';
|
||||||
|
DOMNode: HTMLElement;
|
||||||
|
|
||||||
|
constructor(DomNode: HTMLElement) {
|
||||||
|
this.wrapper = null;
|
||||||
|
this.DOMNode = DomNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
data(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
attrs: getAttributes(this.type, this.DOMNode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class OrderedList extends Node {
|
||||||
|
type = 'orderedList';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'OL';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
export class Paragraph extends Node {
|
||||||
|
type = 'paragraph';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'P';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Status extends Node {
|
||||||
|
type = 'status';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('status');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Table extends Node {
|
||||||
|
type = 'table';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'TBODY' && this.DOMNode.parentNode.nodeName === 'TABLE';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class TableCell extends Node {
|
||||||
|
type = 'tableCell';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'TD';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class TableHeader extends Node {
|
||||||
|
type = 'tableHeader';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'TH';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class TableRow extends Node {
|
||||||
|
type = 'tableRow';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'TR';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class TaskList extends Node {
|
||||||
|
type = 'taskList';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'UL' && this.DOMNode.classList.contains('task-list');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class TaskListItem extends Node {
|
||||||
|
type = 'taskItem';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'LI' && this.DOMNode.classList.contains('task-list-item');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Text extends Node {
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === '#text';
|
||||||
|
}
|
||||||
|
|
||||||
|
data() {
|
||||||
|
const text = this.DOMNode.nodeValue.replace(/^[\n]+/g, '');
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
text: text.trim() || '\n',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Node } from './node';
|
||||||
|
|
||||||
|
export class Title extends Node {
|
||||||
|
type = 'title';
|
||||||
|
|
||||||
|
matching() {
|
||||||
|
return this.DOMNode.nodeName === 'P' && this.DOMNode.classList.contains('title');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,207 @@
|
||||||
|
// 自定义节点
|
||||||
|
import { Iframe } from './nodes/iframe';
|
||||||
|
import { Attachment } from './nodes/attachment';
|
||||||
|
import { Banner } from './nodes/banner';
|
||||||
|
import { Status } from './nodes/status';
|
||||||
|
import { DocumentReference } from './nodes/documentReference';
|
||||||
|
import { DocumentChildren } from './nodes/documentChildren';
|
||||||
|
import { Mind } from './nodes/mind';
|
||||||
|
// 通用
|
||||||
|
import { CodeBlock } from './nodes/codeBlock';
|
||||||
|
import { CodeBlockWrapper } from './nodes/codeBlockWrapper';
|
||||||
|
import { HardBreak } from './nodes/hardBreak';
|
||||||
|
import { Heading } from './nodes/heading';
|
||||||
|
import { Image } from './nodes/image';
|
||||||
|
import { HorizontalRule } from './nodes/horizontalRule';
|
||||||
|
import { Blockquote } from './nodes/blockQuote';
|
||||||
|
// 文本
|
||||||
|
import { Title } from './nodes/title';
|
||||||
|
import { Katex } from './nodes/katex';
|
||||||
|
import { Paragraph } from './nodes/paragraph';
|
||||||
|
import { Text } from './nodes/text';
|
||||||
|
// 表格
|
||||||
|
import { Table } from './nodes/table';
|
||||||
|
import { TableHeader } from './nodes/tableHeader';
|
||||||
|
import { TableRow } from './nodes/tableRow';
|
||||||
|
import { TableCell } from './nodes/tableCell';
|
||||||
|
// 列表
|
||||||
|
import { TaskList } from './nodes/taskList';
|
||||||
|
import { TaskListItem } from './nodes/taskListItem';
|
||||||
|
import { ListItem } from './nodes/listItem';
|
||||||
|
import { OrderedList } from './nodes/orderedList';
|
||||||
|
import { BulletList } from './nodes/bulletList';
|
||||||
|
|
||||||
|
// marks
|
||||||
|
import { Bold } from './marks/bold';
|
||||||
|
import { Code } from './marks/code';
|
||||||
|
import { Italic } from './marks/italic';
|
||||||
|
import { Link } from './marks/link';
|
||||||
|
import { Underline } from './marks/underline';
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
document: HTMLElement;
|
||||||
|
nodes = [];
|
||||||
|
marks = [];
|
||||||
|
storedMarks = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.document = undefined;
|
||||||
|
this.storedMarks = [];
|
||||||
|
|
||||||
|
this.nodes = [
|
||||||
|
Attachment,
|
||||||
|
Banner,
|
||||||
|
Iframe,
|
||||||
|
Status,
|
||||||
|
Mind,
|
||||||
|
DocumentChildren,
|
||||||
|
DocumentReference,
|
||||||
|
|
||||||
|
CodeBlock,
|
||||||
|
CodeBlockWrapper,
|
||||||
|
HardBreak,
|
||||||
|
Heading,
|
||||||
|
Image,
|
||||||
|
HorizontalRule,
|
||||||
|
|
||||||
|
Title,
|
||||||
|
Katex,
|
||||||
|
Paragraph,
|
||||||
|
|
||||||
|
Text,
|
||||||
|
Blockquote,
|
||||||
|
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
TaskList,
|
||||||
|
TaskListItem,
|
||||||
|
OrderedList,
|
||||||
|
ListItem,
|
||||||
|
BulletList,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.marks = [Bold, Code, Italic, Link, Underline];
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocument(document) {
|
||||||
|
this.document = document;
|
||||||
|
}
|
||||||
|
|
||||||
|
stripWhitespace(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocumentBody() {
|
||||||
|
return this.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(value) {
|
||||||
|
this.setDocument(value);
|
||||||
|
const content = this.renderChildren(this.getDocumentBody());
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChildren(node) {
|
||||||
|
let nodes = [];
|
||||||
|
|
||||||
|
node.childNodes.forEach((child) => {
|
||||||
|
const NodeClass = this.getMatchingNode(child);
|
||||||
|
let MarkClass;
|
||||||
|
|
||||||
|
if (NodeClass) {
|
||||||
|
let item = NodeClass.data();
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
if (child.hasChildNodes()) {
|
||||||
|
nodes.push(...this.renderChildren(child));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.hasChildNodes()) {
|
||||||
|
item = {
|
||||||
|
...item,
|
||||||
|
content: this.renderChildren(child),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.storedMarks.length) {
|
||||||
|
item = {
|
||||||
|
...item,
|
||||||
|
marks: this.storedMarks,
|
||||||
|
};
|
||||||
|
this.storedMarks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NodeClass.wrapper) {
|
||||||
|
item.content = [
|
||||||
|
{
|
||||||
|
...NodeClass.wrapper,
|
||||||
|
content: item.content || [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push(item);
|
||||||
|
} else if ((MarkClass = this.getMatchingMark(child))) {
|
||||||
|
this.storedMarks.push(MarkClass.data());
|
||||||
|
|
||||||
|
if (child.hasChildNodes()) {
|
||||||
|
nodes.push(...this.renderChildren(child));
|
||||||
|
}
|
||||||
|
} else if (child.hasChildNodes()) {
|
||||||
|
nodes.push(...this.renderChildren(child));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatchingNode(item) {
|
||||||
|
return this.getMatchingClass(item, this.nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatchingMark(item) {
|
||||||
|
return this.getMatchingClass(item, this.marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatchingClass(node, classes) {
|
||||||
|
for (let i in classes) {
|
||||||
|
const Class = classes[i];
|
||||||
|
const instance = new Class(node);
|
||||||
|
if (instance.matching()) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(node) {
|
||||||
|
this.nodes.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
addNodes(nodes) {
|
||||||
|
for (const i in nodes) {
|
||||||
|
this.addNode(nodes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMark(mark) {
|
||||||
|
this.marks.push(mark);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMarks(marks) {
|
||||||
|
for (const i in marks) {
|
||||||
|
this.addMark(marks[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { BaseKit } from '../../../basekit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 tiptap extension 的配置从 DOM 节点上获取属性值
|
||||||
|
* @param element
|
||||||
|
* @param ret
|
||||||
|
* @param config
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const getAttribute = (
|
||||||
|
element: HTMLElement,
|
||||||
|
ret = {},
|
||||||
|
config: Record<string, { default: unknown; parseHTML?: (element: HTMLElement) => Record<string, unknown> }>
|
||||||
|
) => {
|
||||||
|
return Object.keys(config).reduce((accu, key) => {
|
||||||
|
const conf = config[key];
|
||||||
|
accu[key] = conf.default;
|
||||||
|
|
||||||
|
if (conf.parseHTML) {
|
||||||
|
// try {
|
||||||
|
accu[key] = conf.parseHTML(element);
|
||||||
|
// } catch (e) {
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
return accu;
|
||||||
|
}, ret);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAttributes = (name: string, element: HTMLElement): Record<string, unknown> => {
|
||||||
|
const ext = BaseKit.find((ext) => ext.name === name);
|
||||||
|
|
||||||
|
if (!ext) return {};
|
||||||
|
|
||||||
|
let { config } = ext;
|
||||||
|
let parent = ext && ext.parent;
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
while (parent.parent) {
|
||||||
|
parent = parent.parent;
|
||||||
|
}
|
||||||
|
config = parent.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) return {};
|
||||||
|
|
||||||
|
const { addGlobalAttributes, addAttributes } = config;
|
||||||
|
const attrs = {};
|
||||||
|
|
||||||
|
if (addGlobalAttributes) {
|
||||||
|
getAttribute(element, attrs, addGlobalAttributes.call(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addAttributes) {
|
||||||
|
getAttribute(element, attrs, addAttributes.call(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs;
|
||||||
|
};
|
|
@ -1,30 +0,0 @@
|
||||||
import markdownit from 'markdown-it';
|
|
||||||
import sub from 'markdown-it-sub';
|
|
||||||
import sup from 'markdown-it-sup';
|
|
||||||
import footnote from 'markdown-it-footnote';
|
|
||||||
import anchor from 'markdown-it-anchor';
|
|
||||||
import tasklist from 'markdown-it-task-lists';
|
|
||||||
import emoji from 'markdown-it-emoji';
|
|
||||||
import katex from '@traptitech/markdown-it-katex';
|
|
||||||
import splitMixedLists from './markedownSplitMixedList';
|
|
||||||
import markdownUnderline from './markdownUnderline';
|
|
||||||
import markdownBanner from './markdownBanner';
|
|
||||||
|
|
||||||
export const markdown = markdownit({
|
|
||||||
html: true,
|
|
||||||
linkify: true,
|
|
||||||
typographer: true,
|
|
||||||
})
|
|
||||||
.enable('strikethrough')
|
|
||||||
.use(sub)
|
|
||||||
.use(sup)
|
|
||||||
.use(footnote)
|
|
||||||
.use(anchor)
|
|
||||||
.use(tasklist, { enable: true })
|
|
||||||
.use(splitMixedLists)
|
|
||||||
.use(markdownUnderline)
|
|
||||||
.use(markdownBanner)
|
|
||||||
.use(emoji)
|
|
||||||
.use(katex);
|
|
||||||
|
|
||||||
export * from './serializer';
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { htmlToPromsemirror } from './htmlToProsemirror';
|
||||||
|
import { markdownToHTML } from './markdownToHTML';
|
||||||
|
export { prosemirrorToMarkdown } from './prosemirrorToMarkdown';
|
||||||
|
export * from './helpers';
|
||||||
|
export * from './markdownSourceMap';
|
||||||
|
|
||||||
|
// 将 markdown 字符串转换为 ProseMirror JSONDocument
|
||||||
|
export const markdownToProsemirror = ({ schema, content, hasTitle }) => {
|
||||||
|
const html = markdownToHTML(content);
|
||||||
|
|
||||||
|
if (!html) return null;
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const { body } = parser.parseFromString(html, 'text/html');
|
||||||
|
body.append(document.createComment(content));
|
||||||
|
return htmlToPromsemirror(body, !hasTitle);
|
||||||
|
};
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { sanitize } from 'dompurify';
|
||||||
|
import markdownit from 'markdown-it';
|
||||||
|
import sub from 'markdown-it-sub';
|
||||||
|
import sup from 'markdown-it-sup';
|
||||||
|
import anchor from 'markdown-it-anchor';
|
||||||
|
import emoji from 'markdown-it-emoji';
|
||||||
|
import katex from './markdownKatex';
|
||||||
|
import tasklist from './markdownTaskList';
|
||||||
|
import splitMixedLists from './markedownSplitMixedList';
|
||||||
|
import markdownUnderline from './markdownUnderline';
|
||||||
|
import markdownBanner from './markdownBanner';
|
||||||
|
import { markdownItTable } from './markdownTable';
|
||||||
|
import { createMarkdownContainer } from './markdownItContainer';
|
||||||
|
|
||||||
|
const markdownAttachment = createMarkdownContainer('attachment');
|
||||||
|
const markdownIframe = createMarkdownContainer('iframe');
|
||||||
|
const markdownStatus = createMarkdownContainer('status');
|
||||||
|
const markdownMind = createMarkdownContainer('mind');
|
||||||
|
const markdownDocumentReference = createMarkdownContainer('documentReference');
|
||||||
|
const markdownDocumentChildren = createMarkdownContainer('documentChildren');
|
||||||
|
|
||||||
|
const markdown = markdownit('commonmark')
|
||||||
|
.enable('strikethrough')
|
||||||
|
.use(sub)
|
||||||
|
.use(sup)
|
||||||
|
.use(anchor)
|
||||||
|
.use(tasklist)
|
||||||
|
.use(splitMixedLists)
|
||||||
|
.use(markdownUnderline)
|
||||||
|
.use(markdownItTable)
|
||||||
|
.use(emoji)
|
||||||
|
.use(katex)
|
||||||
|
// 以下为自定义节点
|
||||||
|
.use(markdownBanner)
|
||||||
|
.use(markdownAttachment)
|
||||||
|
.use(markdownIframe)
|
||||||
|
.use(markdownStatus)
|
||||||
|
.use(markdownMind)
|
||||||
|
.use(markdownDocumentReference)
|
||||||
|
.use(markdownDocumentChildren);
|
||||||
|
|
||||||
|
export const markdownToHTML = (rawMarkdown) => {
|
||||||
|
return sanitize(markdown.render(rawMarkdown), {});
|
||||||
|
};
|
|
@ -5,9 +5,8 @@ export const typesAvailable = ['info', 'warning', 'danger', 'success'];
|
||||||
const buildRender = (type) => (tokens, idx, options, env, slf) => {
|
const buildRender = (type) => (tokens, idx, options, env, slf) => {
|
||||||
const tag = tokens[idx];
|
const tag = tokens[idx];
|
||||||
|
|
||||||
// add attributes to the opening tag
|
|
||||||
if (tag.nesting === 1) {
|
if (tag.nesting === 1) {
|
||||||
tag.attrSet('data-banner', type);
|
tag.attrSet('data-type', type);
|
||||||
tag.attrJoin('class', `banner banner-${type}`);
|
tag.attrJoin('class', `banner banner-${type}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import container from 'markdown-it-container';
|
||||||
|
import { strToJSON, jsonToDOMDataset } from '../../dataset';
|
||||||
|
|
||||||
|
export const createMarkdownContainer = (types: string | Array<string>) => (md) => {
|
||||||
|
if (!Array.isArray(types)) {
|
||||||
|
types = [types];
|
||||||
|
}
|
||||||
|
|
||||||
|
types.forEach((type) => {
|
||||||
|
const regexp = new RegExp(`^${type}\\s+(.*)$`);
|
||||||
|
|
||||||
|
md.use(container, type, {
|
||||||
|
validate: function (params) {
|
||||||
|
return params.trim().match(regexp);
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function (tokens, idx, options, env, slf) {
|
||||||
|
const tag = tokens[idx];
|
||||||
|
|
||||||
|
if (tag.nesting === 1) {
|
||||||
|
tag.attrSet('class', type);
|
||||||
|
|
||||||
|
var m = tag.info.trim().match(regexp);
|
||||||
|
if (m[1]) {
|
||||||
|
const data = strToJSON(m[1]);
|
||||||
|
jsonToDOMDataset(data).forEach(({ key, value }) => {
|
||||||
|
tag.attrJoin(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slf.renderToken(tokens, idx, options, env, slf);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return md;
|
||||||
|
};
|
|
@ -0,0 +1,225 @@
|
||||||
|
// var katex = require('katex');
|
||||||
|
|
||||||
|
// Test if potential opening or closing delimieter
|
||||||
|
// Assumes that there is a "$" at state.src[pos]
|
||||||
|
function isValidDelim(state, pos) {
|
||||||
|
var prevChar,
|
||||||
|
nextChar,
|
||||||
|
max = state.posMax,
|
||||||
|
can_open = true,
|
||||||
|
can_close = true;
|
||||||
|
|
||||||
|
prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1;
|
||||||
|
nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1;
|
||||||
|
|
||||||
|
// Check non-whitespace conditions for opening and closing, and
|
||||||
|
// check that closing delimeter isn't followed by a number
|
||||||
|
if (
|
||||||
|
prevChar === 0x20 /* " " */ ||
|
||||||
|
prevChar === 0x09 /* \t */ ||
|
||||||
|
(nextChar >= 0x30 /* "0" */ && nextChar <= 0x39) /* "9" */
|
||||||
|
) {
|
||||||
|
can_close = false;
|
||||||
|
}
|
||||||
|
if (nextChar === 0x20 /* " " */ || nextChar === 0x09 /* \t */) {
|
||||||
|
can_open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
can_open: can_open,
|
||||||
|
can_close: can_close,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function math_inline(state, silent) {
|
||||||
|
var start, match, token, res, pos, esc_count;
|
||||||
|
|
||||||
|
if (state.src[state.pos] !== '$') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
res = isValidDelim(state, state.pos);
|
||||||
|
if (!res.can_open) {
|
||||||
|
if (!silent) {
|
||||||
|
state.pending += '$';
|
||||||
|
}
|
||||||
|
state.pos += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check for and bypass all properly escaped delimieters
|
||||||
|
// This loop will assume that the first leading backtick can not
|
||||||
|
// be the first character in state.src, which is known since
|
||||||
|
// we have found an opening delimieter already.
|
||||||
|
start = state.pos + 1;
|
||||||
|
match = start;
|
||||||
|
while ((match = state.src.indexOf('$', match)) !== -1) {
|
||||||
|
// Found potential $, look for escapes, pos will point to
|
||||||
|
// first non escape when complete
|
||||||
|
pos = match - 1;
|
||||||
|
while (state.src[pos] === '\\') {
|
||||||
|
pos -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even number of escapes, potential closing delimiter found
|
||||||
|
if ((match - pos) % 2 == 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No closing delimter found. Consume $ and continue.
|
||||||
|
if (match === -1) {
|
||||||
|
if (!silent) {
|
||||||
|
state.pending += '$';
|
||||||
|
}
|
||||||
|
state.pos = start;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have empty content, ie: $$. Do not parse.
|
||||||
|
if (match - start === 0) {
|
||||||
|
if (!silent) {
|
||||||
|
state.pending += '$$';
|
||||||
|
}
|
||||||
|
state.pos = start + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid closing delimiter
|
||||||
|
res = isValidDelim(state, match);
|
||||||
|
if (!res.can_close) {
|
||||||
|
if (!silent) {
|
||||||
|
state.pending += '$';
|
||||||
|
}
|
||||||
|
state.pos = start;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
token = state.push('math_inline', 'math', 0);
|
||||||
|
token.markup = '$';
|
||||||
|
token.content = state.src.slice(start, match);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pos = match + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function math_block(state, start, end, silent) {
|
||||||
|
var firstLine,
|
||||||
|
lastLine,
|
||||||
|
next,
|
||||||
|
lastPos,
|
||||||
|
found = false,
|
||||||
|
token,
|
||||||
|
pos = state.bMarks[start] + state.tShift[start],
|
||||||
|
max = state.eMarks[start];
|
||||||
|
|
||||||
|
if (pos + 2 > max) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.src.slice(pos, pos + 2) !== '$$') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += 2;
|
||||||
|
firstLine = state.src.slice(pos, max);
|
||||||
|
|
||||||
|
if (silent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (firstLine.trim().slice(-2) === '$$') {
|
||||||
|
// Single line expression
|
||||||
|
firstLine = firstLine.trim().slice(0, -2);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (next = start; !found; ) {
|
||||||
|
next++;
|
||||||
|
|
||||||
|
if (next >= end) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = state.bMarks[next] + state.tShift[next];
|
||||||
|
max = state.eMarks[next];
|
||||||
|
|
||||||
|
if (pos < max && state.tShift[next] < state.blkIndent) {
|
||||||
|
// non-empty line with negative indent should stop the list:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.src.slice(pos, max).trim().slice(-2) === '$$') {
|
||||||
|
lastPos = state.src.slice(0, max).lastIndexOf('$$');
|
||||||
|
lastLine = state.src.slice(pos, lastPos);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.line = next + 1;
|
||||||
|
|
||||||
|
token = state.push('math_block', 'math', 0);
|
||||||
|
token.block = true;
|
||||||
|
token.content =
|
||||||
|
(firstLine && firstLine.trim() ? firstLine + '\n' : '') +
|
||||||
|
state.getLines(start + 1, next, state.tShift[start], true) +
|
||||||
|
(lastLine && lastLine.trim() ? lastLine : '');
|
||||||
|
token.map = [start, state.line];
|
||||||
|
token.markup = '$$';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
var katex = {
|
||||||
|
renderToString: (s, opts) => s,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function math_plugin(md, options) {
|
||||||
|
// Default options
|
||||||
|
|
||||||
|
options = options || {};
|
||||||
|
if (options.katex) {
|
||||||
|
katex = options.katex;
|
||||||
|
}
|
||||||
|
if (!options.blockClass) {
|
||||||
|
options.blockClass = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var inlineRenderer = function (tokens, idx) {
|
||||||
|
return katexBlock(tokens[idx].content);
|
||||||
|
};
|
||||||
|
|
||||||
|
var katexBlock = function (latex) {
|
||||||
|
options.displayMode = true;
|
||||||
|
try {
|
||||||
|
return `<span class="katex ${options.blockClass}" data-text="${katex.renderToString(latex, options)}"></span>`;
|
||||||
|
} catch (error) {
|
||||||
|
if (options.throwOnError) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
return `<span class='katex katex-error ${options.blockClass}' data-error='${escapeHtml(
|
||||||
|
error.toString()
|
||||||
|
)}' data-text="${escapeHtml(latex)}"></span>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var blockRenderer = function (tokens, idx) {
|
||||||
|
return katexBlock(tokens[idx].content) + '\n';
|
||||||
|
};
|
||||||
|
|
||||||
|
md.inline.ruler.after('escape', 'math_inline', math_inline);
|
||||||
|
md.block.ruler.after('blockquote', 'math_block', math_block, {
|
||||||
|
alt: ['paragraph', 'reference', 'blockquote', 'list'],
|
||||||
|
});
|
||||||
|
md.renderer.rules.math_inline = inlineRenderer;
|
||||||
|
md.renderer.rules.math_block = blockRenderer;
|
||||||
|
}
|
|
@ -0,0 +1,283 @@
|
||||||
|
// Copied from https://github.com/markdown-it/markdown-it/blob/master/lib/rules_block/table.js
|
||||||
|
|
||||||
|
function isSpace(code) {
|
||||||
|
switch (code) {
|
||||||
|
case 0x09:
|
||||||
|
case 0x20:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLine(state, line) {
|
||||||
|
var pos = state.bMarks[line] + state.tShift[line],
|
||||||
|
max = state.eMarks[line];
|
||||||
|
|
||||||
|
return state.src.substr(pos, max - pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapedSplit(str) {
|
||||||
|
var result = [],
|
||||||
|
pos = 0,
|
||||||
|
max = str.length,
|
||||||
|
ch,
|
||||||
|
isEscaped = false,
|
||||||
|
lastPos = 0,
|
||||||
|
current = '';
|
||||||
|
|
||||||
|
ch = str.charCodeAt(pos);
|
||||||
|
|
||||||
|
while (pos < max) {
|
||||||
|
if (ch === 0x7c /* | */) {
|
||||||
|
if (!isEscaped) {
|
||||||
|
// pipe separating cells, '|'
|
||||||
|
result.push(current + str.substring(lastPos, pos));
|
||||||
|
current = '';
|
||||||
|
lastPos = pos + 1;
|
||||||
|
} else {
|
||||||
|
// escaped pipe, '\|'
|
||||||
|
current += str.substring(lastPos, pos - 1);
|
||||||
|
lastPos = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEscaped = ch === 0x5c /* \ */;
|
||||||
|
pos++;
|
||||||
|
|
||||||
|
ch = str.charCodeAt(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(current + str.substring(lastPos));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function table(state, startLine, endLine, silent) {
|
||||||
|
var ch,
|
||||||
|
lineText,
|
||||||
|
pos,
|
||||||
|
i,
|
||||||
|
l,
|
||||||
|
nextLine,
|
||||||
|
columns,
|
||||||
|
columnCount,
|
||||||
|
token,
|
||||||
|
aligns,
|
||||||
|
t,
|
||||||
|
tableLines,
|
||||||
|
tbodyLines,
|
||||||
|
oldParentType,
|
||||||
|
terminate,
|
||||||
|
terminatorRules,
|
||||||
|
firstCh,
|
||||||
|
secondCh;
|
||||||
|
|
||||||
|
// should have at least two lines
|
||||||
|
if (startLine + 2 > endLine) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLine = startLine + 1;
|
||||||
|
|
||||||
|
if (state.sCount[nextLine] < state.blkIndent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it's indented more than 3 spaces, it should be a code block
|
||||||
|
if (state.sCount[nextLine] - state.blkIndent >= 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// first character of the second line should be '|', '-', ':',
|
||||||
|
// and no other characters are allowed but spaces;
|
||||||
|
// basically, this is the equivalent of /^[-:|][-:|\s]*$/ regexp
|
||||||
|
|
||||||
|
pos = state.bMarks[nextLine] + state.tShift[nextLine];
|
||||||
|
if (pos >= state.eMarks[nextLine]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstCh = state.src.charCodeAt(pos++);
|
||||||
|
if (firstCh !== 0x7c /* | */ && firstCh !== 0x2d /* - */ && firstCh !== 0x3a /* : */) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos >= state.eMarks[nextLine]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
secondCh = state.src.charCodeAt(pos++);
|
||||||
|
if (secondCh !== 0x7c /* | */ && secondCh !== 0x2d /* - */ && secondCh !== 0x3a /* : */ && !isSpace(secondCh)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if first character is '-', then second character must not be a space
|
||||||
|
// (due to parsing ambiguity with list)
|
||||||
|
if (firstCh === 0x2d /* - */ && isSpace(secondCh)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pos < state.eMarks[nextLine]) {
|
||||||
|
ch = state.src.charCodeAt(pos);
|
||||||
|
|
||||||
|
if (ch !== 0x7c /* | */ && ch !== 0x2d /* - */ && ch !== 0x3a /* : */ && !isSpace(ch)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineText = getLine(state, startLine + 1);
|
||||||
|
|
||||||
|
columns = lineText.split('|');
|
||||||
|
aligns = [];
|
||||||
|
for (i = 0; i < columns.length; i++) {
|
||||||
|
t = columns[i].trim();
|
||||||
|
if (!t) {
|
||||||
|
// allow empty columns before and after table, but not in between columns;
|
||||||
|
// e.g. allow ` |---| `, disallow ` ---||--- `
|
||||||
|
if (i === 0 || i === columns.length - 1) {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^:?-+:?$/.test(t)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (t.charCodeAt(t.length - 1) === 0x3a /* : */) {
|
||||||
|
aligns.push(t.charCodeAt(0) === 0x3a /* : */ ? 'center' : 'right');
|
||||||
|
} else if (t.charCodeAt(0) === 0x3a /* : */) {
|
||||||
|
aligns.push('left');
|
||||||
|
} else {
|
||||||
|
aligns.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lineText = getLine(state, startLine).trim();
|
||||||
|
if (lineText.indexOf('|') === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.sCount[startLine] - state.blkIndent >= 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
columns = escapedSplit(lineText);
|
||||||
|
if (columns.length && columns[0] === '') columns.shift();
|
||||||
|
if (columns.length && columns[columns.length - 1] === '') columns.pop();
|
||||||
|
|
||||||
|
// header row will define an amount of columns in the entire table,
|
||||||
|
// and align row should be exactly the same (the rest of the rows can differ)
|
||||||
|
columnCount = columns.length;
|
||||||
|
if (columnCount === 0 || columnCount !== aligns.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
oldParentType = state.parentType;
|
||||||
|
state.parentType = 'table';
|
||||||
|
|
||||||
|
// use 'blockquote' lists for termination because it's
|
||||||
|
// the most similar to tables
|
||||||
|
terminatorRules = state.md.block.ruler.getRules('blockquote');
|
||||||
|
|
||||||
|
token = state.push('table_open', 'table', 1);
|
||||||
|
token.map = tableLines = [startLine, 0];
|
||||||
|
|
||||||
|
token = state.push('thead_open', 'thead', 1);
|
||||||
|
token.map = [startLine, startLine + 1];
|
||||||
|
|
||||||
|
token = state.push('tr_open', 'tr', 1);
|
||||||
|
token.map = [startLine, startLine + 1];
|
||||||
|
|
||||||
|
for (i = 0; i < columns.length; i++) {
|
||||||
|
token = state.push('th_open', 'th', 1);
|
||||||
|
if (aligns[i]) {
|
||||||
|
token.attrs = [['style', 'text-align:' + aligns[i]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
token = state.push('paragraph_open', 'p', 1);
|
||||||
|
token = state.push('inline', '', 0);
|
||||||
|
token.content = columns[i].trim();
|
||||||
|
token.children = [];
|
||||||
|
token = state.push('paragraph_close', 'p', -1);
|
||||||
|
|
||||||
|
token = state.push('th_close', 'th', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
token = state.push('tr_close', 'tr', -1);
|
||||||
|
token = state.push('thead_close', 'thead', -1);
|
||||||
|
|
||||||
|
for (nextLine = startLine + 2; nextLine < endLine; nextLine++) {
|
||||||
|
if (state.sCount[nextLine] < state.blkIndent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate = false;
|
||||||
|
for (i = 0, l = terminatorRules.length; i < l; i++) {
|
||||||
|
if (terminatorRules[i](state, nextLine, endLine, true)) {
|
||||||
|
terminate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminate) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lineText = getLine(state, nextLine).trim();
|
||||||
|
if (!lineText) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (state.sCount[nextLine] - state.blkIndent >= 4) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
columns = escapedSplit(lineText);
|
||||||
|
if (columns.length && columns[0] === '') columns.shift();
|
||||||
|
if (columns.length && columns[columns.length - 1] === '') columns.pop();
|
||||||
|
|
||||||
|
if (nextLine === startLine + 2) {
|
||||||
|
token = state.push('tbody_open', 'tbody', 1);
|
||||||
|
token.map = tbodyLines = [startLine + 2, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
token = state.push('tr_open', 'tr', 1);
|
||||||
|
token.map = [nextLine, nextLine + 1];
|
||||||
|
|
||||||
|
for (i = 0; i < columnCount; i++) {
|
||||||
|
token = state.push('td_open', 'td', 1);
|
||||||
|
if (aligns[i]) {
|
||||||
|
token.attrs = [['style', 'text-align:' + aligns[i]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
token = state.push('paragraph_open', 'p', 1);
|
||||||
|
token = state.push('inline', '', 0);
|
||||||
|
token.content = columns[i].trim();
|
||||||
|
token.children = [];
|
||||||
|
token = state.push('paragraph_close', 'p', -1);
|
||||||
|
|
||||||
|
token = state.push('td_close', 'td', -1);
|
||||||
|
}
|
||||||
|
token = state.push('tr_close', 'tr', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tbodyLines) {
|
||||||
|
token = state.push('tbody_close', 'tbody', -1);
|
||||||
|
tbodyLines[1] = nextLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = state.push('table_close', 'table', -1);
|
||||||
|
tableLines[1] = nextLine;
|
||||||
|
|
||||||
|
state.parentType = oldParentType;
|
||||||
|
state.line = nextLine;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markdownItTable = (md, options) => {
|
||||||
|
md.block.ruler.before('paragraph', 'table', table, {
|
||||||
|
alt: ['paragraph', 'reference'],
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,175 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: ISC
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Markdown-it plugin to render GitHub-style task lists; see
|
||||||
|
//
|
||||||
|
// https://github.com/blog/1375-task-lists-in-gfm-issues-pulls-comments
|
||||||
|
// https://github.com/blog/1825-task-lists-in-all-markdown-documents
|
||||||
|
|
||||||
|
import MarkdownIt from 'markdown-it/lib';
|
||||||
|
import StateCore from 'markdown-it/lib/rules_core/state_core';
|
||||||
|
import Token from 'markdown-it/lib/token';
|
||||||
|
|
||||||
|
interface TaskListsOptions {
|
||||||
|
enabled: boolean;
|
||||||
|
label: boolean;
|
||||||
|
lineNumber: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxRegex = /^ *\[([ x])\] /i;
|
||||||
|
|
||||||
|
export default function markdownItTaskLists(
|
||||||
|
md: MarkdownIt,
|
||||||
|
options: TaskListsOptions = { enabled: true, label: true, lineNumber: false }
|
||||||
|
): void {
|
||||||
|
md.core.ruler.after('inline', 'github-task-lists', (state) => processToken(state, options));
|
||||||
|
md.renderer.rules.taskListItemCheckbox = (tokens) => {
|
||||||
|
const token = tokens[0];
|
||||||
|
const checkedAttribute = token.attrGet('checked') ? 'checked=""' : '';
|
||||||
|
const disabledAttribute = token.attrGet('disabled') ? 'disabled=""' : '';
|
||||||
|
const id = token.attrGet('id');
|
||||||
|
const line = token.attrGet('line');
|
||||||
|
const idAttribute = `id="${id}"`;
|
||||||
|
const dataLineAttribute = line && options.lineNumber ? `data-line="${line}"` : '';
|
||||||
|
|
||||||
|
return `<label contenteditable="false"><input class="task-list-item-checkbox" type="checkbox" ${checkedAttribute} ${disabledAttribute} ${dataLineAttribute} ${idAttribute}"><span></span></label>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
md.renderer.rules.taskListItemLabel_close = () => {
|
||||||
|
return '</p></div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
md.renderer.rules.taskListItemLabel_open = (tokens) => {
|
||||||
|
const token = tokens[0];
|
||||||
|
const id = token.attrGet('id');
|
||||||
|
return `<div><p>`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrSet(token, name, value) {
|
||||||
|
var index = token.attrIndex(name);
|
||||||
|
var attr = [name, value];
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
token.attrPush(attr);
|
||||||
|
} else {
|
||||||
|
token.attrs[index] = attr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processToken(state: StateCore, options: TaskListsOptions): boolean {
|
||||||
|
const allTokens = state.tokens;
|
||||||
|
|
||||||
|
attrSet(allTokens[0], 'class', 'contains-task-list');
|
||||||
|
|
||||||
|
for (let i = 2; i < allTokens.length; i++) {
|
||||||
|
if (!isTodoItem(allTokens, i)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isChecked } = todoify(allTokens[i], options);
|
||||||
|
allTokens[i - 2].attrJoin('class', `task-list-item`);
|
||||||
|
allTokens[i - 2].attrJoin('data-checked', isChecked ? `true` : `false`);
|
||||||
|
|
||||||
|
const parentToken = findParentToken(allTokens, i - 2);
|
||||||
|
if (parentToken) {
|
||||||
|
parentToken.attrJoin('class', 'task-list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findParentToken(tokens: Token[], index: number): Token | undefined {
|
||||||
|
const targetLevel = tokens[index].level - 1;
|
||||||
|
for (let currentTokenIndex = index - 1; currentTokenIndex >= 0; currentTokenIndex--) {
|
||||||
|
if (tokens[currentTokenIndex].level === targetLevel) {
|
||||||
|
return tokens[currentTokenIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTodoItem(tokens: Token[], index: number): boolean {
|
||||||
|
return (
|
||||||
|
isInline(tokens[index]) &&
|
||||||
|
isParagraph(tokens[index - 1]) &&
|
||||||
|
isListItem(tokens[index - 2]) &&
|
||||||
|
startsWithTodoMarkdown(tokens[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoify(token: Token, options: TaskListsOptions) {
|
||||||
|
if (token.children == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateIdForToken(token);
|
||||||
|
|
||||||
|
const { checkbox, isChecked } = createCheckboxToken(token, options.enabled, id);
|
||||||
|
token.children.splice(0, 0, checkbox);
|
||||||
|
token.children[1].content = token.children[1].content.replace(checkboxRegex, '');
|
||||||
|
|
||||||
|
if (options.label) {
|
||||||
|
token.children.splice(1, 0, createLabelBeginToken(id));
|
||||||
|
token.children.push(createLabelEndToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isChecked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIdForToken(token: Token): string {
|
||||||
|
if (token.map) {
|
||||||
|
return `task-item-${token.map[0]}`;
|
||||||
|
} else {
|
||||||
|
return `task-item-${Math.ceil(Math.random() * (10000 * 1000) - 1000)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCheckboxToken(token: Token, enabled: boolean, id: string): Token {
|
||||||
|
const checkbox = new Token('taskListItemCheckbox', '', 0);
|
||||||
|
if (!enabled) {
|
||||||
|
checkbox.attrSet('disabled', 'true');
|
||||||
|
}
|
||||||
|
if (token.map) {
|
||||||
|
checkbox.attrSet('line', token.map[0].toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
checkbox.attrSet('id', id);
|
||||||
|
|
||||||
|
const checkboxRegexResult = checkboxRegex.exec(token.content);
|
||||||
|
const isChecked = !!checkboxRegexResult && checkboxRegexResult[1].toLowerCase() === 'x';
|
||||||
|
if (isChecked) {
|
||||||
|
checkbox.attrSet('checked', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checkbox, isChecked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLabelBeginToken(id: string): Token {
|
||||||
|
const labelBeginToken = new Token('taskListItemLabel_open', '', 1);
|
||||||
|
labelBeginToken.attrSet('id', id);
|
||||||
|
return labelBeginToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLabelEndToken(): Token {
|
||||||
|
return new Token('taskListItemLabel_close', '', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInline(token: Token): boolean {
|
||||||
|
return token.type === 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParagraph(token: Token): boolean {
|
||||||
|
return token.type === 'paragraph_open';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isListItem(token: Token): boolean {
|
||||||
|
return token.type === 'list_item_open';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithTodoMarkdown(token: Token): boolean {
|
||||||
|
return checkboxRegex.test(token.content);
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ export default function splitMixedLists(md) {
|
||||||
|
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
const token = tokens[i];
|
const token = tokens[i];
|
||||||
if (token.attrGet('class') !== 'contains-task-list') {
|
if (token.attrGet('class') !== 'task-list') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const firstChild = tokens[i + 1];
|
const firstChild = tokens[i + 1];
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||||
|
import { Attachment } from '../../../extensions/attachment';
|
||||||
|
import { Banner } from '../../../extensions/banner';
|
||||||
|
import { Bold } from '../../../extensions/bold';
|
||||||
|
import { BulletList } from '../../../extensions/bulletList';
|
||||||
|
import { Code } from '../../../extensions/code';
|
||||||
|
import { CodeBlock } from '../../../extensions/codeBlock';
|
||||||
|
import { DocumentChildren } from '../../../extensions/documentChildren';
|
||||||
|
import { DocumentReference } from '../../../extensions/documentReference';
|
||||||
|
import { HardBreak } from '../../../extensions/hardBreak';
|
||||||
|
import { Heading } from '../../../extensions/heading';
|
||||||
|
import { HorizontalRule } from '../../../extensions/horizontalRule';
|
||||||
|
import { marks } 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 { 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 { TaskItem } from '../../../extensions/taskItem';
|
||||||
|
import { TaskList } from '../../../extensions/taskList';
|
||||||
|
import { TextStyle } from '../../../extensions/textStyle';
|
||||||
|
import { Title } from '../../../extensions/title';
|
||||||
|
import {
|
||||||
|
isPlainURL,
|
||||||
|
renderHardBreak,
|
||||||
|
renderTable,
|
||||||
|
renderTableCell,
|
||||||
|
renderTableRow,
|
||||||
|
openTag,
|
||||||
|
closeTag,
|
||||||
|
renderOrderedList,
|
||||||
|
renderImage,
|
||||||
|
renderCustomContainer,
|
||||||
|
renderHTMLNode,
|
||||||
|
} from './serializerHelpers';
|
||||||
|
|
||||||
|
const SerializerConfig = {
|
||||||
|
marks: {
|
||||||
|
[Bold.name]: defaultMarkdownSerializer.marks.strong,
|
||||||
|
[Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
|
||||||
|
[Code.name]: defaultMarkdownSerializer.marks.code,
|
||||||
|
[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,
|
||||||
|
},
|
||||||
|
// FIXME: 如何导出 style?
|
||||||
|
[TextStyle.name]: { open: '', close: '', mixable: true, expelEnclosingWhitespace: true },
|
||||||
|
...marks.reduce(
|
||||||
|
(acc, { name, tag }) => ({
|
||||||
|
...acc,
|
||||||
|
[name]: {
|
||||||
|
mixable: true,
|
||||||
|
open(state, node) {
|
||||||
|
return openTag(tag, node.attrs);
|
||||||
|
},
|
||||||
|
close: closeTag(tag),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
nodes: {
|
||||||
|
[Attachment.name]: renderCustomContainer('attachment'),
|
||||||
|
[Banner.name]: (state, node) => {
|
||||||
|
state.write(`:::${node.attrs.type || 'info'}\n`);
|
||||||
|
state.ensureNewLine();
|
||||||
|
state.renderContent(node);
|
||||||
|
state.ensureNewLine();
|
||||||
|
state.write(':::');
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
blockquote: (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);
|
||||||
|
},
|
||||||
|
[DocumentChildren.name]: renderCustomContainer('documentChildren'),
|
||||||
|
[DocumentReference.name]: renderCustomContainer('documentReference'),
|
||||||
|
[HardBreak.name]: renderHardBreak,
|
||||||
|
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
|
||||||
|
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
|
||||||
|
[Iframe.name]: renderCustomContainer('iframe'),
|
||||||
|
[Image.name]: renderImage,
|
||||||
|
[Katex.name]: (state, node) => {
|
||||||
|
state.ensureNewLine();
|
||||||
|
state.write(`\$\$${node.attrs.text || ''}\$\$`);
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
|
||||||
|
[Mind.name]: renderCustomContainer('mind'),
|
||||||
|
[OrderedList.name]: renderOrderedList,
|
||||||
|
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
|
||||||
|
[Status.name]: renderCustomContainer('status'),
|
||||||
|
[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) => {
|
||||||
|
state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' ');
|
||||||
|
},
|
||||||
|
[Text.name]: defaultMarkdownSerializer.nodes.text,
|
||||||
|
[Title.name]: renderHTMLNode('p', false, true, { class: 'title' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ProseMirror Document Node JSON 转换为 markdown 字符串
|
||||||
|
* @param param.content
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const prosemirrorToMarkdown = ({ content }) => {
|
||||||
|
const serializer = new ProseMirrorMarkdownSerializer(SerializerConfig.nodes, SerializerConfig.marks);
|
||||||
|
const markdown = serializer.serialize(content, {
|
||||||
|
tightLists: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
};
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { jsonToStr } from '../../dataset';
|
||||||
|
|
||||||
const uniq = (arr: string[]) => [...new Set(arr)];
|
const uniq = (arr: string[]) => [...new Set(arr)];
|
||||||
|
|
||||||
function isString(value) {
|
function isString(value) {
|
||||||
|
@ -271,7 +273,7 @@ export function renderHTMLNode(tagName, forceRenderInline = false, needNewLine =
|
||||||
renderTagClose(state, tagName, false);
|
renderTagClose(state, tagName, false);
|
||||||
if (needNewLine) {
|
if (needNewLine) {
|
||||||
state.ensureNewLine();
|
state.ensureNewLine();
|
||||||
state.write('<br />');
|
state.write('\n');
|
||||||
state.ensureNewLine();
|
state.ensureNewLine();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -346,3 +348,11 @@ export function renderImage(state, node) {
|
||||||
export function renderPlayable(state, node) {
|
export function renderPlayable(state, node) {
|
||||||
renderImage(state, node);
|
renderImage(state, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderCustomContainer(name) {
|
||||||
|
return function (state, node) {
|
||||||
|
state.ensureNewLine();
|
||||||
|
state.write(`::: ${name} ${jsonToStr(node.attrs)}\n:::\n`);
|
||||||
|
state.closeBlock(node);
|
||||||
|
};
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue