mirror of https://github.com/fantasticit/think.git
feat: improve editor
parent
c8c370e0cb
commit
cc30e00984
|
@ -51,6 +51,7 @@
|
|||
"@tiptap/extension-text-style": "^2.0.0-beta.23",
|
||||
"@tiptap/extension-underline": "^2.0.0-beta.23",
|
||||
"@tiptap/react": "^2.0.0-beta.107",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.90",
|
||||
"@traptitech/markdown-it-katex": "^3.5.0",
|
||||
"axios": "^0.25.0",
|
||||
"classnames": "^2.3.1",
|
||||
|
@ -62,6 +63,8 @@
|
|||
"lowlight": "^2.5.0",
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it-anchor": "^8.4.1",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"markdown-it-footnote": "^3.0.3",
|
||||
"markdown-it-sub": "^1.0.0",
|
||||
"markdown-it-sup": "^1.0.0",
|
||||
|
@ -69,11 +72,13 @@
|
|||
"marked": "^4.0.12",
|
||||
"next": "12.0.10",
|
||||
"prosemirror-markdown": "^1.7.0",
|
||||
"prosemirror-utils": "^0.9.6",
|
||||
"prosemirror-view": "^1.23.6",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-split-pane": "^0.1.92",
|
||||
"scroll-into-view-if-needed": "^2.2.29",
|
||||
"swr": "^1.2.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
|
|
|
@ -13,12 +13,12 @@ export const DocumentContent: React.FC<IProps> = ({ document }) => {
|
|||
const c = safeJSONParse(document.content);
|
||||
let json = c.default || c;
|
||||
|
||||
if (json && json.content) {
|
||||
json = {
|
||||
type: 'doc',
|
||||
content: json.content.slice(1),
|
||||
};
|
||||
}
|
||||
// if (json && json.content) {
|
||||
// json = {
|
||||
// type: 'doc',
|
||||
// content: json.content.slice(1),
|
||||
// };
|
||||
// }
|
||||
|
||||
const editor = useEditor({
|
||||
editable: false,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useMemo, useEffect } from 'react';
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import { ILoginUser } from '@think/domains';
|
||||
import { IDocument, ILoginUser } from '@think/domains';
|
||||
import { useToggle } from 'hooks/useToggle';
|
||||
import {
|
||||
DEFAULT_EXTENSION,
|
||||
|
@ -13,6 +13,7 @@ import {
|
|||
} from 'components/tiptap';
|
||||
import { DataRender } from 'components/data-render';
|
||||
import { joinUser } from 'components/document/collaboration';
|
||||
import { CreateUser } from './user';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
@ -20,11 +21,13 @@ const { Content } = Layout;
|
|||
interface IProps {
|
||||
user: ILoginUser;
|
||||
documentId: string;
|
||||
document: IDocument;
|
||||
}
|
||||
|
||||
export const Editor: React.FC<IProps> = ({ user, documentId }) => {
|
||||
export const Editor: React.FC<IProps> = ({ user, documentId, document }) => {
|
||||
if (!user) return null;
|
||||
|
||||
const $ref = useRef();
|
||||
const provider = useMemo(() => {
|
||||
return getProvider({
|
||||
targetId: documentId,
|
||||
|
@ -68,7 +71,15 @@ export const Editor: React.FC<IProps> = ({ user, documentId }) => {
|
|||
return (
|
||||
<>
|
||||
<Content className={styles.editorWrap}>
|
||||
<EditorContent editor={editor} />
|
||||
<div id="js-reader-container">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
<CreateUser
|
||||
document={document}
|
||||
container={() =>
|
||||
window.document.querySelector('#js-reader-container .ProseMirror .title')
|
||||
}
|
||||
/>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -122,10 +122,12 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
|
|||
return (
|
||||
<>
|
||||
<Seo title={document.title} />
|
||||
<Editor key={document.id} user={user} documentId={document.id} />
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<CreateUser document={document} />
|
||||
</div>
|
||||
<Editor
|
||||
key={document.id}
|
||||
user={user}
|
||||
documentId={document.id}
|
||||
document={document}
|
||||
/>
|
||||
<div className={styles.commentWrap}>
|
||||
<CommentEditor documentId={document.id} />
|
||||
</div>
|
||||
|
|
|
@ -131,11 +131,15 @@ export const DocumentPublicReader: React.FC<IProps> = ({ documentId, hideLogo =
|
|||
style={{ fontSize }}
|
||||
id="js-share-document-editor-container"
|
||||
>
|
||||
<Title style={{ fontSize: '2.4em' }}>{data.title}</Title>
|
||||
<div style={{ margin: '24px 0' }}>
|
||||
<CreateUser document={data} />
|
||||
</div>
|
||||
<DocumentContent document={data} />
|
||||
<CreateUser
|
||||
document={data}
|
||||
container={() =>
|
||||
window.document.querySelector(
|
||||
'#js-share-document-editor-container .ProseMirror .title'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<BackTop
|
||||
target={() =>
|
||||
|
|
|
@ -1,15 +1,26 @@
|
|||
import { createPortal } from 'react-dom';
|
||||
import { Space, Typography, Avatar } from '@douyinfe/semi-ui';
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
import { IDocument } from '@think/domains';
|
||||
import { LocaleTime } from 'components/locale-time';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export const CreateUser: React.FC<{ document: IDocument }> = ({ document }) => {
|
||||
export const CreateUser: React.FC<{ document: IDocument; container: () => HTMLElement }> = ({
|
||||
document,
|
||||
container = null,
|
||||
}) => {
|
||||
if (!document.createUser) return null;
|
||||
|
||||
return (
|
||||
<Text type="tertiary" size="small">
|
||||
const content = (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
marginTop: 24,
|
||||
padding: '16px 0',
|
||||
fontSize: 13,
|
||||
fontWeight: 'normal',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Avatar size="extra-extra-small" src={document.createUser && document.createUser.avatar}>
|
||||
<IconUser />
|
||||
|
@ -27,6 +38,11 @@ export const CreateUser: React.FC<{ document: IDocument }> = ({ document }) => {
|
|||
</p>
|
||||
</div>
|
||||
</Space>
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
const el = container && container();
|
||||
|
||||
if (!el) return content;
|
||||
return createPortal(content, el);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
export const IconSearchReplace: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
|
||||
return (
|
||||
<Icon
|
||||
style={style}
|
||||
svg={
|
||||
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
|
||||
<g fill="currentColor" fill-rule="evenodd">
|
||||
<g fill-rule="nonzero">
|
||||
<path d="M191.527 15c18.302 0 33.173 14.688 33.469 32.919l.004.554v65.424c0 5.523-4.477 10-10 10-5.43 0-9.848-4.327-9.996-9.72l-.004-.28V48.473c0-7.338-5.865-13.305-13.163-13.47l-.31-.003H64.473c-7.338 0-13.305 5.865-13.47 13.163l-.003.31v159.054c0 7.338 5.865 13.305 13.163 13.47l.31.003H99c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L99 241H64.473c-18.302 0-33.173-14.688-33.469-32.919l-.004-.554V48.473C31 30.17 45.688 15.3 63.919 15.004l.554-.004h127.054Z"></path>
|
||||
<path d="M147.385 150.885c-17.964 17.964-17.964 47.09 0 65.054s47.09 17.964 65.054 0c17.964-17.965 17.964-47.09 0-65.054-17.965-17.964-47.09-17.964-65.054 0Zm14.142 14.142c10.154-10.154 26.616-10.154 36.77 0 10.153 10.154 10.153 26.616 0 36.77-10.154 10.153-26.616 10.153-36.77 0-10.154-10.154-10.154-26.616 0-36.77Z"></path>
|
||||
<path d="M234.545 241.752c-3.839 3.84-10.023 3.904-13.941.196l-.2-.196-21.921-21.92c-3.905-3.905-3.905-10.237 0-14.142 3.839-3.84 10.023-3.904 13.941-.195l.2.195 21.921 21.92c3.905 3.905 3.905 10.237 0 14.142Z"></path>
|
||||
</g>
|
||||
<path d="M92 71h72c5.523 0 10 4.477 10 10s-4.477 10-10 10H92c-5.523 0-10-4.477-10-10s4.477-10 10-10ZM92 125h26c5.523 0 10 4.477 10 10s-4.477 10-10 10H92c-5.523 0-10-4.477-10-10s4.477-10 10-10Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -34,3 +34,4 @@ export * from './IconSplitCell';
|
|||
export * from './IconAttachment';
|
||||
export * from './IconMath';
|
||||
export * from './IconSearch';
|
||||
export * from './IconSearchReplace';
|
||||
|
|
|
@ -11,6 +11,7 @@ import { ColorHighlighter } from './extensions/colorHighlighter';
|
|||
import { DocumentChildren } from './extensions/documentChildren';
|
||||
import { DocumentReference } from './extensions/documentReference';
|
||||
import { Dropcursor } from './extensions/dropCursor';
|
||||
import { Emoji } from './extensions/emoji';
|
||||
import { FontSize } from './extensions/fontSize';
|
||||
import { FootnoteDefinition } from './extensions/footnoteDefinition';
|
||||
import { FootnoteReference } from './extensions/footnoteReference';
|
||||
|
@ -33,6 +34,7 @@ import { Paragraph } from './extensions/paragraph';
|
|||
import { PasteFile } from './extensions/pasteFile';
|
||||
import { PasteMarkdown } from './extensions/pasteMarkdown';
|
||||
import { Placeholder } from './extensions/placeholder';
|
||||
import { SearchNReplace } from './extensions/search';
|
||||
import { Status } from './extensions/status';
|
||||
import { Strike } from './extensions/strike';
|
||||
import { Table } from './extensions/table';
|
||||
|
@ -62,6 +64,7 @@ export const BaseKit = [
|
|||
DocumentChildren,
|
||||
DocumentReference,
|
||||
Dropcursor,
|
||||
Emoji,
|
||||
FontSize,
|
||||
FootnoteDefinition,
|
||||
FootnoteReference,
|
||||
|
@ -83,15 +86,8 @@ export const BaseKit = [
|
|||
Paragraph,
|
||||
PasteFile,
|
||||
PasteMarkdown,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'title') {
|
||||
return '请输入标题';
|
||||
}
|
||||
return '请输入内容';
|
||||
},
|
||||
showOnlyWhenEditable: true,
|
||||
}),
|
||||
Placeholder,
|
||||
SearchNReplace,
|
||||
Status,
|
||||
Strike,
|
||||
Table,
|
||||
|
@ -99,9 +95,7 @@ export const BaseKit = [
|
|||
TableHeader,
|
||||
TableRow,
|
||||
Text,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph', 'image'],
|
||||
}),
|
||||
TextAlign,
|
||||
TextStyle,
|
||||
TaskItem,
|
||||
TaskList,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconDownload } from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { download } from '../../services/download';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
|
@ -12,7 +13,7 @@ export const AttachmentWrapper = ({ node }) => {
|
|||
<div className={styles.wrap}>
|
||||
<span>{name}</span>
|
||||
<span>
|
||||
<Tooltip zIndex={10000} content="下载">
|
||||
<Tooltip content="下载">
|
||||
<Button
|
||||
theme={'borderless'}
|
||||
type="tertiary"
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||
import { Button, Select, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconCopy } from '@douyinfe/semi-icons';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
// @ts-ignore
|
||||
import { lowlight } from 'lowlight';
|
||||
import { copy } from 'helpers/copy';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,38 @@
|
|||
.items {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
padding: 0.2rem;
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--semi-color-text-0);
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
background-color: var(--semi-color-bg-0);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.2rem 0.4rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--semi-color-info);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: var(--semi-color-info);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import cls from 'classnames';
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
interface IProps {
|
||||
items: any[];
|
||||
command: any;
|
||||
}
|
||||
|
||||
export const EmojiList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||
const $container = useRef<HTMLDivElement>();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
|
||||
if (item) {
|
||||
props.command(item);
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = $container.current.querySelector(`button:nth-of-type(${selectedIndex + 1})`);
|
||||
el && scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.items}>
|
||||
<div ref={$container}>
|
||||
{props.items.map((item, index) => (
|
||||
<button
|
||||
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji}:{item.name}:
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -21,7 +21,7 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => {
|
|||
const content = text ? (
|
||||
<span contentEditable={false} dangerouslySetInnerHTML={{ __html: formatText }}></span>
|
||||
) : (
|
||||
<span contentEditable={false}>请输入公式</span>
|
||||
<span contentEditable={false}>点击输入公式</span>
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -32,7 +32,7 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => {
|
|||
content={
|
||||
<div style={{ width: 320 }}>
|
||||
<TextArea
|
||||
autofocus
|
||||
autoFocus
|
||||
placeholder="输入公式"
|
||||
autosize
|
||||
rows={3}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import ReactDOM from 'react-dom';
|
||||
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Space, Button } from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { IconZoomOut, IconZoomIn } from 'components/icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { Divider } from '../divider';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import styles from './index.module.scss';
|
|||
export const StatusWrapper = ({ editor, node, updateAttributes }) => {
|
||||
const isEditable = editor.isEditable;
|
||||
const { color, text } = node.attrs;
|
||||
const content = <Tag color={color}>{text || '设置状态'}</Tag>;
|
||||
const content = <Tag color={color}>{text || '点击设置状态'}</Tag>;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className={styles.wrap}>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Node, Command, mergeAttributes } from '@tiptap/core';
|
||||
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { BannerWrapper } from '../components/banner';
|
||||
import { typesAvailable } from '../services/markdown/markdownBanner';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
|
@ -12,33 +13,53 @@ declare module '@tiptap/core' {
|
|||
|
||||
export const Banner = Node.create({
|
||||
name: 'banner',
|
||||
content: 'block*',
|
||||
content: 'paragraph+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
types: typesAvailable,
|
||||
HTMLAttributes: {
|
||||
class: 'banner',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
type: {
|
||||
default: 'info',
|
||||
rendered: false,
|
||||
parseHTML: (element) => element.getAttribute('data-banner'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-banner': attributes.type,
|
||||
'class': `banner banner-${attributes.type}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div' }];
|
||||
return [
|
||||
{
|
||||
tag: 'div',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
{ class: 'banner' },
|
||||
[
|
||||
'div',
|
||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
||||
0,
|
||||
],
|
||||
];
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const { class: classy } = this.options.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
|
||||
|
@ -57,6 +78,18 @@ export const Banner = Node.create({
|
|||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
find: /^:::([\dA-Za-z]*) $/,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
return { type: match[1] };
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(BannerWrapper);
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote';
|
||||
import { wrappingInputRule } from '@tiptap/core';
|
||||
import { getParents } from '../services/dom';
|
||||
import { getMarkdownSource } from '../services/markdownSourceMap';
|
||||
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
|
||||
|
||||
export const Blockquote = BuiltInBlockquote.extend({
|
||||
addAttributes() {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list';
|
||||
import { getMarkdownSource } from '../services/markdownSourceMap';
|
||||
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
|
||||
import { listInputRule } from '../services/listInputRule';
|
||||
|
||||
export const BulletList = BuiltInBulletList.extend({
|
||||
addAttributes() {
|
||||
|
@ -16,4 +17,8 @@ export const BulletList = BuiltInBulletList.extend({
|
|||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [listInputRule(/^\s*([-+*])\s([^\s[])$/, this.type)];
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import { Node } from '@tiptap/core';
|
||||
import { ReactRenderer } from '@tiptap/react';
|
||||
import { PluginKey } from 'prosemirror-state';
|
||||
import Suggestion from '@tiptap/suggestion';
|
||||
import tippy from 'tippy.js';
|
||||
import { EmojiList } from '../components/emojiList';
|
||||
import { emojiSearch, emojisToName } from '../components/emojiList/emojis';
|
||||
|
||||
export const EmojiPluginKey = new PluginKey('emoji');
|
||||
export { emojisToName };
|
||||
export const Emoji = Node.create({
|
||||
name: 'emoji',
|
||||
content: 'text*',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
suggestion: {
|
||||
char: ':',
|
||||
pluginKey: EmojiPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, props.emoji + ' ')
|
||||
.run();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
addCommands() {
|
||||
return {
|
||||
emoji:
|
||||
(emojiObject) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent(emojiObject.emoji + ' ');
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
suggestion: {
|
||||
items: ({ query }) => {
|
||||
return emojiSearch(query);
|
||||
},
|
||||
render: () => {
|
||||
let component;
|
||||
let popup;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(EmojiList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props);
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { Command, Extension } from '@tiptap/core';
|
||||
import { sinkListItem, liftListItem } from 'prosemirror-schema-list';
|
||||
import { TextSelection, AllSelection, Transaction } from 'prosemirror-state';
|
||||
import { isListActive } from '../services/active';
|
||||
import { isListActive } from '../services/isActive';
|
||||
import { clamp } from '../services/clamp';
|
||||
import { getNodeType } from '../services/type';
|
||||
import { isListNode } from '../services/node';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
|
||||
import { Node, Command, mergeAttributes, nodeInputRule } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { KatexWrapper } from '../components/katex';
|
||||
|
||||
|
@ -55,7 +55,7 @@ export const Katex = Node.create({
|
|||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
nodeInputRule({
|
||||
find: KatexInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
|
|
|
@ -59,4 +59,8 @@ export const Link = BuiltInLink.extend({
|
|||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
openOnClick: false,
|
||||
linkOnPaste: true,
|
||||
autolink: true,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { MindWrapper } from '../components/mind';
|
||||
|
||||
|
@ -81,4 +81,16 @@ export const Mind = Node.create({
|
|||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MindWrapper);
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: /^\$mind $/,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
return { type: match[1] };
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list';
|
||||
import { getMarkdownSource } from '../services/markdownSourceMap';
|
||||
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
|
||||
|
||||
export const OrderedList = BuiltInOrderedList.extend({
|
||||
addAttributes() {
|
||||
|
|
|
@ -1,56 +1,133 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||
import { markdownSerializer } from '../services/serializer';
|
||||
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
|
||||
// @ts-ignore
|
||||
import { lowlight } from 'lowlight';
|
||||
import { markdownSerializer } from '../services/markdown';
|
||||
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
|
||||
|
||||
const TEXT_FORMAT = 'text/plain';
|
||||
const HTML_FORMAT = 'text/html';
|
||||
const VS_CODE_FORMAT = 'vscode-editor-data';
|
||||
const isMarkActive =
|
||||
(type) =>
|
||||
(state: EditorState): boolean => {
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { from, $from, to, empty } = state.selection;
|
||||
|
||||
return empty
|
||||
? type.isInSet(state.storedMarks || $from.marks())
|
||||
: state.doc.rangeHasMark(from, to, type);
|
||||
};
|
||||
|
||||
export default function isInCode(state: EditorState): boolean {
|
||||
if (state.schema.nodes.codeBlock) {
|
||||
const $head = state.selection.$head;
|
||||
for (let d = $head.depth; d > 0; d--) {
|
||||
if ($head.node(d).type === state.schema.nodes.codeBlock) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isMarkActive(state.schema.marks.code)(state);
|
||||
}
|
||||
|
||||
const LANGUAGES = lowlight.listLanguages().reduce((a, language) => {
|
||||
a[language] = language;
|
||||
return a;
|
||||
}, {});
|
||||
|
||||
export const acceptedMimes = {
|
||||
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
|
||||
};
|
||||
|
||||
function isMarkdown(text: string): boolean {
|
||||
// code-ish
|
||||
const fences = text.match(/^```/gm);
|
||||
if (fences && fences.length > 1) return true;
|
||||
|
||||
// link-ish
|
||||
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
|
||||
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
|
||||
|
||||
// heading-ish
|
||||
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
|
||||
|
||||
// list-ish
|
||||
const listItems = text.match(/^[\d-*].?\s\S+/gm);
|
||||
if (listItems && listItems.length > 1) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizePastedMarkdown(text: string): string {
|
||||
const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim;
|
||||
|
||||
while (text.match(CHECKBOX_REGEX)) {
|
||||
text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export const PasteMarkdown = Extension.create({
|
||||
name: 'pasteMarkdown',
|
||||
priority: EXTENSION_PRIORITY_HIGHEST,
|
||||
// @ts-ignore
|
||||
addCommands() {
|
||||
return {
|
||||
pasteMarkdown: (markdown) => () => {
|
||||
const { editor } = this;
|
||||
const { state, view } = editor;
|
||||
const { tr, selection } = state;
|
||||
|
||||
const document = markdownSerializer.deserialize({
|
||||
schema: view.props.state.schema,
|
||||
content: markdown,
|
||||
});
|
||||
|
||||
// tr.replaceWith(selection.from - 1, selection.to, document.content);
|
||||
// view.dispatch(tr);
|
||||
const transaction = view.state.tr.replaceSelectionWith(document);
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('pasteMarkdown'),
|
||||
props: {
|
||||
handlePaste: (_, event) => {
|
||||
const { clipboardData } = event;
|
||||
const content = clipboardData.getData(TEXT_FORMAT);
|
||||
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
|
||||
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
|
||||
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
|
||||
const language = vsCodeMeta.mode;
|
||||
|
||||
if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
|
||||
// @ts-ignore
|
||||
handlePaste: async (view, event: ClipboardEvent) => {
|
||||
if (view.props.editable && !view.props.editable(view.state)) {
|
||||
return false;
|
||||
}
|
||||
if (!event.clipboardData) return false;
|
||||
|
||||
// @ts-ignore
|
||||
this.editor.commands.pasteMarkdown(content);
|
||||
return true;
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
const html = event.clipboardData.getData('text/html');
|
||||
const vscode = event.clipboardData.getData('vscode-editor-data');
|
||||
|
||||
// 粘贴代码
|
||||
if (isInCode(view.state)) {
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.insertText(text));
|
||||
return true;
|
||||
}
|
||||
|
||||
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
|
||||
const pasteCodeLanguage = vscodeMeta?.mode;
|
||||
|
||||
if (pasteCodeLanguage && pasteCodeLanguage !== 'markdown') {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
view.state.tr.replaceSelectionWith(
|
||||
view.state.schema.nodes.codeBlock.create({
|
||||
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
|
||||
? vscodeMeta.mode
|
||||
: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
view.dispatch(view.state.tr.insertText(text));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理 markdown
|
||||
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
||||
event.preventDefault();
|
||||
const paste = markdownSerializer.deserialize({
|
||||
schema: view.props.state.schema,
|
||||
content: normalizePastedMarkdown(text),
|
||||
});
|
||||
// @ts-ignore
|
||||
const transaction = view.state.tr.replaceSelectionWith(paste);
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content);
|
||||
|
|
|
@ -1,3 +1,12 @@
|
|||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import BuiltInPlaceholder from '@tiptap/extension-placeholder';
|
||||
|
||||
export { Placeholder };
|
||||
export const Placeholder = BuiltInPlaceholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'title') {
|
||||
return '请输入标题';
|
||||
}
|
||||
return '请输入内容';
|
||||
},
|
||||
showOnlyCurrent: false,
|
||||
showOnlyWhenEditable: true,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,362 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||
import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
search: {
|
||||
/**
|
||||
* @description Set search term in extension.
|
||||
*/
|
||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set replace term in extension.
|
||||
*/
|
||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Replace first instance of search result with given replace term.
|
||||
*/
|
||||
replace: () => ReturnType;
|
||||
/**
|
||||
* @description Replace all instances of search result with given replace term.
|
||||
*/
|
||||
replaceAll: () => ReturnType;
|
||||
goToNextSearchResult: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Result {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
interface SearchOptions {
|
||||
searchTerm: string;
|
||||
replaceTerm: string;
|
||||
results: Result[];
|
||||
currentIndex: number;
|
||||
searchResultClass: string;
|
||||
searchResultCurrentClass: string;
|
||||
caseSensitive: boolean;
|
||||
disableRegex: boolean;
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const updateView = (state: EditorState<any>, dispatch: any) => dispatch(state.tr);
|
||||
|
||||
const regex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
||||
return RegExp(
|
||||
disableRegex ? s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') : s,
|
||||
caseSensitive ? 'gu' : 'gui'
|
||||
);
|
||||
};
|
||||
|
||||
function processSearches(
|
||||
doc: ProsemirrorNode,
|
||||
searchTerm: RegExp,
|
||||
searchResultClass: string
|
||||
): { decorationsToReturn: any[]; results: Result[] } {
|
||||
const decorations: Decoration[] = [];
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
const results: Result[] = [];
|
||||
|
||||
let index = 0;
|
||||
|
||||
if (!searchTerm) return { decorationsToReturn: [], results: [] };
|
||||
|
||||
doc?.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (textNodesWithPosition[index]) {
|
||||
textNodesWithPosition[index] = {
|
||||
text: textNodesWithPosition[index].text + node.text,
|
||||
pos: textNodesWithPosition[index].pos,
|
||||
};
|
||||
} else {
|
||||
textNodesWithPosition[index] = {
|
||||
text: `${node.text}`,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||
|
||||
for (let i = 0; i < textNodesWithPosition.length; i += 1) {
|
||||
const { text, pos } = textNodesWithPosition[i];
|
||||
|
||||
const matches = [...text.matchAll(searchTerm)];
|
||||
|
||||
for (let j = 0; j < matches.length; j += 1) {
|
||||
const m = matches[j];
|
||||
|
||||
if (m[0] === '') break;
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
decorations.push(Decoration.inline(r.from, r.to, { class: searchResultClass }));
|
||||
}
|
||||
|
||||
return {
|
||||
decorationsToReturn: decorations,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
const replace = (replaceTerm: string, results: Result[], { state, dispatch }: any) => {
|
||||
const firstResult = results[0];
|
||||
|
||||
if (!firstResult) return;
|
||||
|
||||
const { from, to } = results[0];
|
||||
|
||||
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
|
||||
};
|
||||
|
||||
const rebaseNextResult = (
|
||||
replaceTerm: string,
|
||||
index: number,
|
||||
lastOffset: number,
|
||||
results: Result[]
|
||||
): [number, Result[]] | null => {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
if (!results[nextIndex]) return null;
|
||||
|
||||
const { from: currentFrom, to: currentTo } = results[index];
|
||||
|
||||
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
|
||||
|
||||
const { from, to } = results[nextIndex];
|
||||
|
||||
results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset,
|
||||
};
|
||||
|
||||
return [offset, results];
|
||||
};
|
||||
|
||||
const replaceAll = (replaceTerm: string, results: Result[], { tr, dispatch }: any) => {
|
||||
let offset = 0;
|
||||
|
||||
let ourResults = results.slice();
|
||||
|
||||
if (!ourResults.length) return false;
|
||||
|
||||
for (let i = 0; i < ourResults.length; i += 1) {
|
||||
const { from, to } = ourResults[i];
|
||||
|
||||
tr.insertText(replaceTerm, from, to);
|
||||
|
||||
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, ourResults);
|
||||
|
||||
if (rebaseNextResultResponse) {
|
||||
offset = rebaseNextResultResponse[0];
|
||||
ourResults = rebaseNextResultResponse[1];
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, gotoIndex }) => {
|
||||
const result = searchResults[gotoIndex];
|
||||
|
||||
if (result) {
|
||||
let transaction = tr.setMeta('directDecoration', {
|
||||
fromPos: result.from,
|
||||
toPos: result.to,
|
||||
attrs: { class: searchResultCurrentClass },
|
||||
});
|
||||
view?.dispatch(transaction);
|
||||
|
||||
setTimeout(() => {
|
||||
const el = window.document.querySelector(`.${searchResultCurrentClass}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export const SearchNReplace = Extension.create<SearchOptions>({
|
||||
name: 'search',
|
||||
|
||||
defaultOptions: {
|
||||
searchTerm: '',
|
||||
replaceTerm: '',
|
||||
results: [],
|
||||
currentIndex: 0,
|
||||
searchResultClass: 'search-result',
|
||||
searchResultCurrentClass: 'search-result-current',
|
||||
caseSensitive: false,
|
||||
disableRegex: false,
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSearchTerm:
|
||||
(searchTerm: string) =>
|
||||
({ state, dispatch }) => {
|
||||
this.options.searchTerm = searchTerm;
|
||||
this.options.results = [];
|
||||
this.options.currentIndex = 0;
|
||||
|
||||
updateView(state, dispatch);
|
||||
|
||||
return false;
|
||||
},
|
||||
setReplaceTerm:
|
||||
(replaceTerm: string) =>
|
||||
({ state, dispatch }) => {
|
||||
this.options.replaceTerm = replaceTerm;
|
||||
|
||||
updateView(state, dispatch);
|
||||
|
||||
return false;
|
||||
},
|
||||
replace:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
const { replaceTerm, results, currentIndex } = this.options;
|
||||
const currentResult = results[currentIndex];
|
||||
|
||||
if (currentResult) {
|
||||
replace(replaceTerm, [currentResult], { state, dispatch });
|
||||
this.options.results.splice(currentIndex, 1);
|
||||
} else {
|
||||
replace(replaceTerm, results, { state, dispatch });
|
||||
|
||||
this.options.results.shift();
|
||||
}
|
||||
|
||||
updateView(state, dispatch);
|
||||
|
||||
return false;
|
||||
},
|
||||
replaceAll:
|
||||
() =>
|
||||
({ state, tr, dispatch }) => {
|
||||
const { replaceTerm, results } = this.options;
|
||||
|
||||
replaceAll(replaceTerm, results, { tr, dispatch });
|
||||
|
||||
this.options.results = [];
|
||||
|
||||
updateView(state, dispatch);
|
||||
|
||||
return false;
|
||||
},
|
||||
goToPrevSearchResult:
|
||||
() =>
|
||||
({ view, tr }) => {
|
||||
const { currentIndex, results, searchResultCurrentClass } = this.options;
|
||||
const nextIndex = (currentIndex + results.length - 1) % results.length;
|
||||
this.options.currentIndex = nextIndex;
|
||||
|
||||
return gotoSearchResult({
|
||||
view,
|
||||
tr,
|
||||
searchResults: results,
|
||||
searchResultCurrentClass,
|
||||
gotoIndex: nextIndex,
|
||||
});
|
||||
},
|
||||
goToNextSearchResult:
|
||||
() =>
|
||||
({ view, tr }) => {
|
||||
const { currentIndex, results, searchResultCurrentClass } = this.options;
|
||||
const nextIndex = (currentIndex + 1) % results.length;
|
||||
this.options.currentIndex = nextIndex;
|
||||
|
||||
return gotoSearchResult({
|
||||
view,
|
||||
tr,
|
||||
searchResults: results,
|
||||
searchResultCurrentClass,
|
||||
gotoIndex: nextIndex,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const extensionThis = this;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('search'),
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(ctx) {
|
||||
const { doc, docChanged } = ctx;
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
searchResultClass,
|
||||
searchResultCurrentClass,
|
||||
disableRegex,
|
||||
caseSensitive,
|
||||
} = extensionThis.options;
|
||||
|
||||
if (docChanged || searchTerm) {
|
||||
const { decorationsToReturn, results } = processSearches(
|
||||
doc,
|
||||
regex(searchTerm, disableRegex, caseSensitive),
|
||||
searchResultClass
|
||||
);
|
||||
extensionThis.options.results = results;
|
||||
|
||||
if (ctx.getMeta('directDecoration')) {
|
||||
const { fromPos, toPos, attrs } = ctx.getMeta('directDecoration');
|
||||
decorationsToReturn.push(Decoration.inline(fromPos, toPos, attrs));
|
||||
} else {
|
||||
if (results.length) {
|
||||
decorationsToReturn[0] = Decoration.inline(results[0].from, results[0].to, {
|
||||
class: searchResultCurrentClass,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorationsToReturn);
|
||||
}
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -1,7 +1,10 @@
|
|||
import { wrappingInputRule } from '@tiptap/core';
|
||||
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { findParentNodeClosestToPos } from 'prosemirror-utils';
|
||||
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
|
||||
|
||||
export const TaskItem = BuiltInTaskItem.extend({
|
||||
const CustomTaskItem = BuiltInTaskItem.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
nested: true,
|
||||
|
@ -34,4 +37,49 @@ export const TaskItem = BuiltInTaskItem.extend({
|
|||
},
|
||||
];
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
...this.parent(),
|
||||
wrappingInputRule({
|
||||
find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
checked: 'xX'.includes(match[match.length - 1]),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
// @ts-ignore
|
||||
handleClick: (view, pos, event) => {
|
||||
const state = view.state;
|
||||
const schema = state.schema;
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
const position = state.doc.resolve(coordinates.pos);
|
||||
const parentList = findParentNodeClosestToPos(position, function (node) {
|
||||
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem;
|
||||
});
|
||||
// @ts-ignore
|
||||
const isListClicked = event.target.tagName.toLowerCase() === 'li';
|
||||
if (!isListClicked || !parentList || parentList.node.type !== schema.nodes.taskItem) {
|
||||
return;
|
||||
}
|
||||
const tr = state.tr;
|
||||
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
|
||||
checked: !parentList.node.attrs.checked,
|
||||
});
|
||||
view.dispatch(tr);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const TaskItem = CustomTaskItem.configure({ nested: true });
|
||||
|
|
|
@ -1,32 +1,12 @@
|
|||
import { mergeAttributes } from '@tiptap/core';
|
||||
import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list';
|
||||
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
|
||||
import { getMarkdownSource } from '../services/markdownSourceMap';
|
||||
|
||||
export const TaskList = BuiltInTaskList.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
numeric: {
|
||||
default: false,
|
||||
parseHTML: (element) => element.tagName.toLowerCase() === 'ol',
|
||||
},
|
||||
start: {
|
||||
default: 1,
|
||||
parseHTML: (element) =>
|
||||
element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1,
|
||||
},
|
||||
|
||||
parens: {
|
||||
default: false,
|
||||
parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: '.task-list',
|
||||
tag: 'ul.task-list',
|
||||
priority: PARSE_HTML_PRIORITY_HIGHEST,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import BuiltInTextAlign from '@tiptap/extension-text-align';
|
||||
|
||||
export { TextAlign };
|
||||
export const TextAlign = BuiltInTextAlign.configure({
|
||||
types: ['heading', 'paragraph', 'image'],
|
||||
});
|
||||
|
|
|
@ -2,8 +2,12 @@ import { Node, mergeAttributes } from '@tiptap/core';
|
|||
|
||||
export const Title = Node.create({
|
||||
name: 'title',
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
content: 'inline*',
|
||||
selectable: true,
|
||||
defining: true,
|
||||
inline: false,
|
||||
group: 'basic',
|
||||
allowGapCursor: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { PluginKey, Plugin } from 'prosemirror-state';
|
||||
|
||||
// @ts-ignore
|
||||
/**
|
||||
* @param {object} args Arguments as deconstructable object
|
||||
* @param {Array | object} args.types possible types
|
||||
* @param {object} args.node node to check
|
||||
*/
|
||||
function nodeEqualsType({ types, node }) {
|
||||
return (Array.isArray(types) && types.includes(node.type)) || node.type === types;
|
||||
}
|
||||
|
||||
export interface TrailingNodeOptions {
|
||||
node: string;
|
||||
notAfter: string[];
|
||||
}
|
||||
/**
|
||||
* Extension based on:
|
||||
* - https://github.com/ueberdosis/tiptap/tree/main/demos/src/Experiments/TrailingNode
|
||||
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
|
||||
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
|
||||
*/
|
||||
|
||||
export const TrailingNode = Extension.create<TrailingNodeOptions>({
|
||||
export const TrailingNode = Extension.create({
|
||||
name: 'trailingNode',
|
||||
|
||||
addOptions() {
|
||||
|
@ -27,10 +33,14 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
|
|||
.map(([, value]) => value)
|
||||
.filter((node) => this.options.notAfter.includes(node.name));
|
||||
|
||||
const isEditable = this.editor.isEditable;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: plugin,
|
||||
appendTransaction: (_, __, state) => {
|
||||
if (!isEditable) return;
|
||||
|
||||
const { doc, tr, schema } = state;
|
||||
const shouldInsertNodeAtEnd = plugin.getState(state);
|
||||
const endPosition = doc.content.size;
|
||||
|
@ -44,17 +54,18 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
|
|||
},
|
||||
state: {
|
||||
init: (_, state) => {
|
||||
if (!isEditable) return false;
|
||||
const lastNode = state.tr.doc.lastChild;
|
||||
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
|
||||
},
|
||||
apply: (tr, value) => {
|
||||
if (!isEditable) return value;
|
||||
|
||||
if (!tr.docChanged) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const lastNode = tr.doc.lastChild;
|
||||
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
|
||||
},
|
||||
},
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
IconAlignRight,
|
||||
IconAlignJustify,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../services/active';
|
||||
import { isTitleActive } from '../services/isActive';
|
||||
|
||||
export const AlignMenu = ({ editor }) => {
|
||||
const current = (() => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Space, Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconTickCircle,
|
||||
|
@ -6,22 +6,23 @@ import {
|
|||
IconClear,
|
||||
IconInfoCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { BubbleMenu } from './components/bubble-menu';
|
||||
import { Divider } from '../components/divider';
|
||||
import { Banner } from '../extensions/banner';
|
||||
import { deleteNode } from '../services//delete';
|
||||
import { deleteNode } from '../services/deleteNode';
|
||||
|
||||
export const BannerBubbleMenu = ({ editor }) => {
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
className={'bubble-menu js-bubble-menu-banner'}
|
||||
editor={editor}
|
||||
pluginKey="banner-bubble-menu"
|
||||
shouldShow={() => editor.isActive(Banner.name)}
|
||||
matchRenderContainer={(node) => node && node.id === 'js-bannber-container'}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="信息" zIndex={10000}>
|
||||
<Tooltip content="信息">
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
@ -39,7 +40,7 @@ export const BannerBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="警告" zIndex={10000}>
|
||||
<Tooltip content="警告">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor
|
||||
|
@ -57,7 +58,7 @@ export const BannerBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="危险" zIndex={10000}>
|
||||
<Tooltip content="危险">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor
|
||||
|
@ -75,7 +76,7 @@ export const BannerBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="成功" zIndex={10000}>
|
||||
<Tooltip content="成功">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor
|
||||
|
@ -95,7 +96,7 @@ export const BannerBubbleMenu = ({ editor }) => {
|
|||
|
||||
<Divider />
|
||||
|
||||
<Tooltip content="删除" zIndex={10000}>
|
||||
<Tooltip content="删除" hideOnClick>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Link } from '../extensions/link';
|
|||
import { Attachment } from '../extensions/attachment';
|
||||
import { Image } from '../extensions/image';
|
||||
import { Banner } from '../extensions/banner';
|
||||
import { HorizontalRule } from '../extensions/horizontalRule';
|
||||
import { Iframe } from '../extensions/iframe';
|
||||
import { Mind } from '../extensions/mind';
|
||||
import { Table } from '../extensions/table';
|
||||
|
@ -26,6 +27,7 @@ const OTHER_BUBBLE_MENU_TYPES = [
|
|||
DocumentReference.name,
|
||||
DocumentChildren.name,
|
||||
Katex.name,
|
||||
HorizontalRule.name,
|
||||
];
|
||||
|
||||
export const BaseBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../services/active';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { isTitleActive } from '../services/isActive';
|
||||
import { Emoji } from './components/emoji';
|
||||
import { Search } from './search';
|
||||
|
||||
export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
if (!editor) {
|
||||
|
@ -13,7 +15,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
<>
|
||||
<Emoji editor={editor} />
|
||||
|
||||
<Tooltip zIndex={10000} content="插入链接">
|
||||
<Tooltip content="插入链接">
|
||||
<Button
|
||||
theme={editor.isActive('link') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -23,7 +25,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="插入引用">
|
||||
<Tooltip content="插入引用">
|
||||
<Button
|
||||
theme={editor.isActive('blockquote') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -34,7 +36,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="插入分割线">
|
||||
<Tooltip content="插入分割线">
|
||||
<Button
|
||||
theme={'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -43,6 +45,8 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
disabled={isTitleActive(editor)}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Search editor={editor} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconBold,
|
||||
IconItalic,
|
||||
|
@ -7,7 +7,8 @@ import {
|
|||
IconUnderline,
|
||||
IconCode,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../services/active';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { isTitleActive } from '../services/isActive';
|
||||
import { ColorMenu } from './color';
|
||||
|
||||
export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
|
@ -17,7 +18,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Tooltip zIndex={10000} content="粗体">
|
||||
<Tooltip content="粗体">
|
||||
<Button
|
||||
theme={editor.isActive('bold') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -27,7 +28,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="斜体">
|
||||
<Tooltip content="斜体">
|
||||
<Button
|
||||
theme={editor.isActive('italic') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -37,7 +38,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="下划线">
|
||||
<Tooltip content="下划线">
|
||||
<Button
|
||||
theme={editor.isActive('underline') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -47,7 +48,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="删除线">
|
||||
<Tooltip content="删除线">
|
||||
<Button
|
||||
theme={editor.isActive('strike') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -57,7 +58,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="行内代码">
|
||||
<Tooltip content="行内代码">
|
||||
<Button
|
||||
theme={editor.isActive('code') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconFont, IconMark } from '@douyinfe/semi-icons';
|
||||
import { isTitleActive } from '../services/active';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { isTitleActive } from '../services/isActive';
|
||||
import { Color } from './components/color';
|
||||
|
||||
export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
|
@ -19,7 +20,7 @@ export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
}}
|
||||
disabled={isTitleActive(editor)}
|
||||
>
|
||||
<Tooltip zIndex={10000} content="文本色">
|
||||
<Tooltip content="文本色">
|
||||
<Button
|
||||
theme={editor.isActive('textStyle') ? 'light' : 'borderless'}
|
||||
type={'tertiary'}
|
||||
|
@ -51,7 +52,7 @@ export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
}}
|
||||
disabled={isTitleActive(editor)}
|
||||
>
|
||||
<Tooltip zIndex={10000} content="背景色">
|
||||
<Tooltip content="背景色">
|
||||
<Button
|
||||
theme={editor.isActive('textStyle') ? 'light' : 'borderless'}
|
||||
type={'tertiary'}
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
.wrap {
|
||||
height: 300px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sectionWrap {
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Popover, Button, Tooltip, Typography } from '@douyinfe/semi-ui';
|
||||
import { Popover, Button, Typography } from '@douyinfe/semi-ui';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { IconEmoji } from 'components/icons';
|
||||
import { EXPRESSIONES, GESTURES } from './constants';
|
||||
import styles from './index.module.scss';
|
||||
|
@ -52,7 +53,7 @@ export const Emoji = ({ editor }) => {
|
|||
}
|
||||
>
|
||||
<span>
|
||||
<Tooltip zIndex={10000} content="插入表情">
|
||||
<Tooltip content="插入表情">
|
||||
<Button theme={'borderless'} type="tertiary" icon={<IconEmoji />} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Select } from '@douyinfe/semi-ui';
|
||||
import { isTitleActive } from '../../services/active';
|
||||
import { isTitleActive } from '../../services/isActive';
|
||||
|
||||
export const FONT_SIZES = [12, 13, 14, 15, 16, 19, 22, 24, 29, 32, 40, 48];
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Select } from '@douyinfe/semi-ui';
|
||||
import { isTitleActive } from '../../services/active';
|
||||
import { isTitleActive } from '../../services/isActive';
|
||||
|
||||
const getCurrentCaretTitle = (editor) => {
|
||||
if (editor.isActive('heading', { level: 1 })) return 1;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Space, Button, Tooltip, InputNumber, Typography } from '@douyinfe/semi-ui';
|
||||
import { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconAlignLeft,
|
||||
IconAlignCenter,
|
||||
|
@ -7,6 +7,7 @@ import {
|
|||
IconUpload,
|
||||
IconDelete,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { Upload } from 'components/upload';
|
||||
import { BubbleMenu } from './components/bubble-menu';
|
||||
import { Divider } from '../components/divider';
|
||||
|
@ -38,7 +39,7 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="左对齐" zIndex={10000}>
|
||||
<Tooltip content="左对齐">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor
|
||||
|
@ -56,7 +57,7 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="居中" zIndex={10000}>
|
||||
<Tooltip content="居中">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor
|
||||
|
@ -74,7 +75,7 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="右对齐" zIndex={10000}>
|
||||
<Tooltip content="右对齐">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor
|
||||
|
@ -148,12 +149,12 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
}}
|
||||
>
|
||||
{() => (
|
||||
<Tooltip content="上传图片" zIndex={10000}>
|
||||
<Tooltip content="上传图片">
|
||||
<Button size="small" type="tertiary" theme="borderless" icon={<IconUpload />} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Upload>
|
||||
<Tooltip content="删除" zIndex={10000}>
|
||||
<Tooltip content="删除" hideOnClick>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Space, Button, Tooltip, Input } from '@douyinfe/semi-ui';
|
||||
import { Space, Button, Input } from '@douyinfe/semi-ui';
|
||||
import { IconExternalOpen, IconUnlink, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { BubbleMenu } from './components/bubble-menu';
|
||||
import { Link } from '../extensions/link';
|
||||
|
||||
|
@ -32,7 +33,7 @@ export const LinkBubbleMenu = ({ editor }) => {
|
|||
setUrl(url);
|
||||
}}
|
||||
/>
|
||||
<Tooltip content="设置链接" zIndex={10000}>
|
||||
<Tooltip content="设置链接">
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
@ -50,7 +51,7 @@ export const LinkBubbleMenu = ({ editor }) => {
|
|||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="去除链接" zIndex={10000}>
|
||||
<Tooltip content="去除链接">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor.chain().unsetLink().run();
|
||||
|
@ -61,7 +62,7 @@ export const LinkBubbleMenu = ({ editor }) => {
|
|||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="访问链接" zIndex={10000}>
|
||||
<Tooltip content="访问链接">
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { IconTask } from 'components/icons';
|
||||
import { isTitleActive } from '../services/active';
|
||||
import { isTitleActive } from '../services/isActive';
|
||||
|
||||
export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
if (!editor) {
|
||||
|
@ -11,7 +12,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Tooltip zIndex={10000} content="无序列表">
|
||||
<Tooltip content="无序列表">
|
||||
<Button
|
||||
theme={editor.isActive('bulletList') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -21,7 +22,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="有序列表">
|
||||
<Tooltip content="有序列表">
|
||||
<Button
|
||||
theme={editor.isActive('orderedList') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -31,7 +32,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="任务列表">
|
||||
<Tooltip content="任务列表">
|
||||
<Button
|
||||
theme={editor.isActive('taskList') ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
|
@ -41,7 +42,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="增加缩进">
|
||||
<Tooltip content="增加缩进">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor.chain().focus().indent().run();
|
||||
|
@ -53,7 +54,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip zIndex={10000} content="减少缩进">
|
||||
<Tooltip content="减少缩进">
|
||||
<Button
|
||||
onClick={() => {
|
||||
editor.chain().focus().outdent().run();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip, Dropdown, Popover } from '@douyinfe/semi-ui';
|
||||
import { Button, Dropdown, Popover } from '@douyinfe/semi-ui';
|
||||
import { IconPlus } from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { Upload } from 'components/upload';
|
||||
import {
|
||||
IconDocument,
|
||||
|
@ -15,7 +16,7 @@ import {
|
|||
IconMath,
|
||||
} from 'components/icons';
|
||||
import { GridSelect } from 'components/grid-select';
|
||||
import { isTitleActive } from '../services/active';
|
||||
import { isTitleActive } from '../services/isActive';
|
||||
import { getImageOriginSize } from '../services/image';
|
||||
|
||||
export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||
|
@ -117,7 +118,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
}
|
||||
>
|
||||
<div>
|
||||
<Tooltip content="插入" zIndex={10000}>
|
||||
<Tooltip content="插入">
|
||||
<Button
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Popover, Button, Typography, Input, Space } from '@douyinfe/semi-ui';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { IconSearchReplace } from 'components/icons';
|
||||
import { SearchNReplace } from '../../extensions/search';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export const Search = ({ editor }) => {
|
||||
const searchExtension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === SearchNReplace.name
|
||||
);
|
||||
const currentIndex = searchExtension ? searchExtension.options.currentIndex : -1;
|
||||
const results = searchExtension ? searchExtension.options.results : [];
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [replaceValue, setReplaceValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
editor?.commands?.setSearchTerm(searchValue);
|
||||
}, [searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
editor?.commands?.setReplaceTerm(replaceValue);
|
||||
}, [replaceValue]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
showArrow
|
||||
zIndex={10000}
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
onVisibleChange={(visible) => {
|
||||
if (!visible) {
|
||||
setSearchValue('');
|
||||
setReplaceValue('');
|
||||
}
|
||||
}}
|
||||
content={
|
||||
<div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="tertiary">查找</Text>
|
||||
<Input
|
||||
autofocus
|
||||
value={searchValue}
|
||||
onChange={(v) => setSearchValue(v)}
|
||||
suffix={results.length ? `${currentIndex + 1}/${results.length}` : ''}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="tertiary">替换为</Text>
|
||||
<Input value={replaceValue} onChange={(v) => setReplaceValue(v)} />
|
||||
</div>
|
||||
<div>
|
||||
<Space>
|
||||
<Button disabled={!results.length} onClick={() => editor.commands.replaceAll()}>
|
||||
全部替换
|
||||
</Button>
|
||||
<Button disabled={!results.length} onClick={() => editor.commands.replace()}>
|
||||
替换
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!results.length}
|
||||
onClick={() => editor.commands.goToPrevSearchResult()}
|
||||
>
|
||||
上一个
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!results.length}
|
||||
onClick={() => editor.commands.goToNextSearchResult()}
|
||||
>
|
||||
下一个
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tooltip content="查找替换">
|
||||
<Button theme={'borderless'} type="tertiary" icon={<IconSearchReplace />} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { Space, Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconAddColumnBefore,
|
||||
IconAddColumnAfter,
|
||||
|
@ -10,6 +10,7 @@ import {
|
|||
IconSplitCell,
|
||||
IconDeleteTable,
|
||||
} from 'components/icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { BubbleMenu } from './components/bubble-menu';
|
||||
import { Table } from '../extensions/table';
|
||||
|
||||
|
@ -28,7 +29,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="向前插入一列" zIndex={10000}>
|
||||
<Tooltip content="向前插入一列">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().addColumnBefore().run()}
|
||||
icon={<IconAddColumnBefore />}
|
||||
|
@ -38,7 +39,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="向后插入一列" zIndex={10000}>
|
||||
<Tooltip content="向后插入一列">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
||||
icon={<IconAddColumnAfter />}
|
||||
|
@ -47,7 +48,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="删除当前列" zIndex={10000}>
|
||||
<Tooltip content="删除当前列">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||
icon={<IconDeleteColumn />}
|
||||
|
@ -57,7 +58,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="向前插入一行" zIndex={10000}>
|
||||
<Tooltip content="向前插入一行">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().addRowBefore().run()}
|
||||
icon={<IconAddRowBefore />}
|
||||
|
@ -67,7 +68,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="向后插入一行" zIndex={10000}>
|
||||
<Tooltip content="向后插入一行">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().addRowAfter().run()}
|
||||
icon={<IconAddRowAfter />}
|
||||
|
@ -77,7 +78,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="删除当前行" zIndex={10000}>
|
||||
<Tooltip content="删除当前行">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||
icon={<IconDeleteRow />}
|
||||
|
@ -87,7 +88,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="合并单元格" zIndex={10000}>
|
||||
<Tooltip content="合并单元格">
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
@ -97,7 +98,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="分离单元格" zIndex={10000}>
|
||||
<Tooltip content="分离单元格">
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
@ -107,7 +108,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="删除表格" zIndex={10000}>
|
||||
<Tooltip content="删除表格" hideOnClick>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { InputRule, wrappingInputRule } from '@tiptap/core';
|
||||
|
||||
/**
|
||||
* Wrapping input handler that will append the content of the last match
|
||||
*
|
||||
* @param {RegExp} find find param for the wrapping input rule
|
||||
* @param {object} type Node Type object
|
||||
* @param {*} getAttributes handler to get the attributes
|
||||
*/
|
||||
export function listInputRule(find, type, getAttributes = null) {
|
||||
const handler = ({ state, range, match }) => {
|
||||
const wrap = wrappingInputRule({ find, type, getAttributes });
|
||||
// @ts-ignore
|
||||
wrap.handler({ state, range, match });
|
||||
// Insert the first character after bullet if there is one
|
||||
if (match.length >= 3) {
|
||||
state.tr.insertText(match[2]);
|
||||
}
|
||||
};
|
||||
return new InputRule({ find, handler });
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
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('commonmark', { html: false, breaks: false })
|
||||
.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,29 @@
|
|||
import container from 'markdown-it-container';
|
||||
|
||||
export const typesAvailable = ['info', 'warning', 'danger', 'success'];
|
||||
|
||||
const buildRender = (type) => (tokens, idx, options, env, slf) => {
|
||||
const tag = tokens[idx];
|
||||
|
||||
// add attributes to the opening tag
|
||||
if (tag.nesting === 1) {
|
||||
tag.attrSet('data-banner', type);
|
||||
tag.attrJoin('class', `banner banner-${type}`);
|
||||
}
|
||||
|
||||
return slf.renderToken(tokens, idx, options, env, slf);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} md Markdown object
|
||||
*/
|
||||
export default function markdownBanner(md) {
|
||||
// create a custom container to each callout type
|
||||
typesAvailable.forEach((type) => {
|
||||
md.use(container, type, {
|
||||
render: buildRender(type),
|
||||
});
|
||||
});
|
||||
|
||||
return md;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
export default function markdownUnderlines(md) {
|
||||
md.inline.ruler2.after('emphasis', 'underline', (state) => {
|
||||
const tokens = state.tokens;
|
||||
|
||||
for (let i = tokens.length - 1; i > 0; i--) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.markup === '__') {
|
||||
if (token.type === 'strong_open') {
|
||||
tokens[i].tag = 'u';
|
||||
tokens[i].type = 'u_open';
|
||||
}
|
||||
if (token.type === 'strong_close') {
|
||||
tokens[i].tag = 'u';
|
||||
tokens[i].type = 'u_close';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @param {object} md Markdown object
|
||||
*/
|
||||
export default function splitMixedLists(md) {
|
||||
md.core.ruler.after('github-task-lists', 'split-mixed-task-lists', (state) => {
|
||||
const tokens = state.tokens;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token.attrGet('class') !== 'contains-task-list') {
|
||||
continue;
|
||||
}
|
||||
const firstChild = tokens[i + 1];
|
||||
const startsWithTask = firstChild.attrGet('class') === 'task-list-item';
|
||||
if (!startsWithTask) {
|
||||
token.attrs.splice(token.attrIndex('class'));
|
||||
if (token.attrs.length === 0) {
|
||||
token.attrs = null;
|
||||
}
|
||||
}
|
||||
const splitBefore = findChildOf(tokens, i, (child) => {
|
||||
return child.nesting === 1 && child.attrGet('class') !== firstChild.attrGet('class');
|
||||
});
|
||||
if (splitBefore > i) {
|
||||
splitListAt(tokens, splitBefore, state.Token);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} tokens - all the tokens in the doc
|
||||
* @param {number} index - index into the tokens array where to split
|
||||
* @param {object} TokenConstructor - constructor provided by Markdown-it
|
||||
*/
|
||||
function splitListAt(tokens, index, TokenConstructor) {
|
||||
const closeList = new TokenConstructor('bullet_list_close', 'ul', -1);
|
||||
closeList.block = true;
|
||||
const openList = new TokenConstructor('bullet_list_open', 'ul', 1);
|
||||
openList.attrSet('class', 'contains-task-list');
|
||||
openList.block = true;
|
||||
tokens.splice(index, 0, closeList, openList);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} tokens - all the tokens in the doc
|
||||
* @param {number} parentIndex - index of the parent in the tokens array
|
||||
* @param {Function} predicate - test function returned child needs to pass
|
||||
*/
|
||||
function findChildOf(tokens, parentIndex, predicate) {
|
||||
const searchLevel = tokens[parentIndex].level + 1;
|
||||
for (let i = parentIndex + 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token.level < searchLevel) {
|
||||
return -1;
|
||||
}
|
||||
if (token.level === searchLevel && predicate(tokens[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
|
@ -4,41 +4,41 @@ import {
|
|||
MarkdownSerializer as ProseMirrorMarkdownSerializer,
|
||||
defaultMarkdownSerializer,
|
||||
} from 'prosemirror-markdown';
|
||||
import { marked } from './marked';
|
||||
import { Attachment } from '../extensions/attachment';
|
||||
import { Banner } from '../extensions/banner';
|
||||
import { Blockquote } from '../extensions/blockquote';
|
||||
import { Bold } from '../extensions/bold';
|
||||
import { BulletList } from '../extensions/bulletList';
|
||||
import { Code } from '../extensions/code';
|
||||
import { CodeBlock } from '../extensions/codeBlock';
|
||||
import { DocumentChildren } from '../extensions/documentChildren';
|
||||
import { DocumentReference } from '../extensions/documentReference';
|
||||
import { FootnoteDefinition } from '../extensions/footnoteDefinition';
|
||||
import { FootnoteReference } from '../extensions/footnoteReference';
|
||||
import { FootnotesSection } from '../extensions/footnotesSection';
|
||||
import { HardBreak } from '../extensions/hardBreak';
|
||||
import { Heading } from '../extensions/heading';
|
||||
import { HorizontalRule } from '../extensions/horizontalRule';
|
||||
import { HTMLMarks } from '../extensions/htmlMarks';
|
||||
import { Iframe } from '../extensions/iframe';
|
||||
import { Image } from '../extensions/image';
|
||||
import { Italic } from '../extensions/italic';
|
||||
import { Katex } from '../extensions/katex';
|
||||
import { Link } from '../extensions/link';
|
||||
import { ListItem } from '../extensions/listItem';
|
||||
import { Mind } from '../extensions/mind';
|
||||
import { OrderedList } from '../extensions/orderedList';
|
||||
import { Paragraph } from '../extensions/paragraph';
|
||||
import { 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 { Title } from '../extensions/title';
|
||||
import { markdown } from '.';
|
||||
import { Attachment } from '../../extensions/attachment';
|
||||
import { Banner } from '../../extensions/banner';
|
||||
import { Blockquote } from '../../extensions/blockquote';
|
||||
import { Bold } from '../../extensions/bold';
|
||||
import { BulletList } from '../../extensions/bulletList';
|
||||
import { Code } from '../../extensions/code';
|
||||
import { CodeBlock } from '../../extensions/codeBlock';
|
||||
import { DocumentChildren } from '../../extensions/documentChildren';
|
||||
import { DocumentReference } from '../../extensions/documentReference';
|
||||
import { FootnoteDefinition } from '../../extensions/footnoteDefinition';
|
||||
import { FootnoteReference } from '../../extensions/footnoteReference';
|
||||
import { FootnotesSection } from '../../extensions/footnotesSection';
|
||||
import { HardBreak } from '../../extensions/hardBreak';
|
||||
import { Heading } from '../../extensions/heading';
|
||||
import { HorizontalRule } from '../../extensions/horizontalRule';
|
||||
import { HTMLMarks } from '../../extensions/htmlMarks';
|
||||
import { Iframe } from '../../extensions/iframe';
|
||||
import { Image } from '../../extensions/image';
|
||||
import { Italic } from '../../extensions/italic';
|
||||
import { Katex } from '../../extensions/katex';
|
||||
import { Link } from '../../extensions/link';
|
||||
import { ListItem } from '../../extensions/listItem';
|
||||
import { Mind } from '../../extensions/mind';
|
||||
import { OrderedList } from '../../extensions/orderedList';
|
||||
import { Paragraph } from '../../extensions/paragraph';
|
||||
import { 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 { Title } from '../../extensions/title';
|
||||
import {
|
||||
isPlainURL,
|
||||
renderHardBreak,
|
||||
|
@ -96,8 +96,11 @@ const defaultSerializerConfig = {
|
|||
state.closeBlock(node);
|
||||
},
|
||||
[Banner.name]: (state, node) => {
|
||||
state.write(`:::${node.attrs.type || 'info'}\n`);
|
||||
state.ensureNewLine();
|
||||
state.write(`banner$`);
|
||||
state.renderContent(node);
|
||||
state.ensureNewLine();
|
||||
state.write(':::');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Blockquote.name]: (state, node) => {
|
||||
|
@ -151,8 +154,10 @@ const defaultSerializerConfig = {
|
|||
},
|
||||
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
|
||||
[Mind.name]: (state, node) => {
|
||||
state.write(`$mind\n`);
|
||||
state.ensureNewLine();
|
||||
state.renderContent(node);
|
||||
state.ensureNewLine();
|
||||
state.write(`mind$`);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[OrderedList.name]: renderOrderedList,
|
||||
|
@ -166,16 +171,22 @@ const defaultSerializerConfig = {
|
|||
state.renderContent(node);
|
||||
},
|
||||
[TaskList.name]: (state, node) => {
|
||||
if (node.attrs.numeric) renderOrderedList(state, node);
|
||||
else defaultMarkdownSerializer.nodes.bullet_list(state, node);
|
||||
state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' ');
|
||||
},
|
||||
[Text.name]: defaultMarkdownSerializer.nodes.text,
|
||||
[Title.name]: renderHTMLNode('h1', true, true, { class: 'title' }),
|
||||
[Title.name]: (state, node) => {
|
||||
if (!node.textContent) return;
|
||||
|
||||
state.write(`# `);
|
||||
state.text(node.textContent, false);
|
||||
state.ensureNewLine();
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderMarkdown = (rawMarkdown) => {
|
||||
return sanitize(marked.render(rawMarkdown), {});
|
||||
return sanitize(markdown.render(rawMarkdown), {});
|
||||
};
|
||||
|
||||
const createMarkdownSerializer = () => ({
|
|
@ -1,4 +1,3 @@
|
|||
// @ts-ignore
|
||||
const uniq = (arr: string[]) => [...new Set(arr)];
|
||||
|
||||
function isString(value) {
|
|
@ -1,15 +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 katex from '@traptitech/markdown-it-katex';
|
||||
|
||||
export const marked = markdownit()
|
||||
.use(sub)
|
||||
.use(sup)
|
||||
.use(footnote)
|
||||
.use(anchor)
|
||||
.use(tasklist)
|
||||
.use(katex);
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { Tooltip as SemiTooltip } from '@douyinfe/semi-ui';
|
||||
import { useToggle } from 'hooks/useToggle';
|
||||
|
||||
let id = 0;
|
||||
|
||||
interface IProps {
|
||||
content: React.ReactNode;
|
||||
hideOnClick?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<IProps> = ({ content, hideOnClick = false, children }) => {
|
||||
const [visible, toggleVisible] = useToggle(false);
|
||||
|
||||
return (
|
||||
<SemiTooltip visible={visible} content={content} zIndex={10000} trigger={'custom'}>
|
||||
<span
|
||||
onMouseEnter={() => {
|
||||
toggleVisible(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
toggleVisible(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
hideOnClick && toggleVisible(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</SemiTooltip>
|
||||
);
|
||||
};
|
|
@ -198,3 +198,18 @@ a {
|
|||
.react-resizable-handle-s {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.react-tooltip {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
background-color: rgba(var(--semi-grey-7), 1) !important;
|
||||
color: var(--semi-color-bg-0) !important;
|
||||
border-radius: var(--semi-border-radius-medium) !important;
|
||||
padding: 6px 8px !important;
|
||||
z-index: 10000 !important;
|
||||
white-space: nowrap;
|
||||
|
||||
&::after {
|
||||
border-top-color: rgba(var(--semi-grey-7), 1) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #ced4da;
|
||||
color: #aaa;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
@ -99,6 +99,7 @@
|
|||
font-weight: bold;
|
||||
color: var(--semi-color-text-0);
|
||||
margin: 10px 0 22px;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
@ -275,6 +276,14 @@
|
|||
width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-result {
|
||||
background: rgb(255, 217, 0);
|
||||
}
|
||||
|
||||
.search-result-current {
|
||||
background: rgb(255, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
|
|
|
@ -85,6 +85,7 @@ importers:
|
|||
'@tiptap/extension-text-style': ^2.0.0-beta.23
|
||||
'@tiptap/extension-underline': ^2.0.0-beta.23
|
||||
'@tiptap/react': ^2.0.0-beta.107
|
||||
'@tiptap/suggestion': ^2.0.0-beta.90
|
||||
'@traptitech/markdown-it-katex': ^3.5.0
|
||||
'@types/node': 17.0.13
|
||||
'@types/react': 17.0.38
|
||||
|
@ -98,6 +99,8 @@ importers:
|
|||
lowlight: ^2.5.0
|
||||
markdown-it: ^12.3.2
|
||||
markdown-it-anchor: ^8.4.1
|
||||
markdown-it-container: ^3.0.0
|
||||
markdown-it-emoji: ^2.0.0
|
||||
markdown-it-footnote: ^3.0.3
|
||||
markdown-it-sub: ^1.0.0
|
||||
markdown-it-sup: ^1.0.0
|
||||
|
@ -105,11 +108,13 @@ importers:
|
|||
marked: ^4.0.12
|
||||
next: 12.0.10
|
||||
prosemirror-markdown: ^1.7.0
|
||||
prosemirror-utils: ^0.9.6
|
||||
prosemirror-view: ^1.23.6
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2
|
||||
react-helmet: ^6.1.0
|
||||
react-split-pane: ^0.1.92
|
||||
scroll-into-view-if-needed: ^2.2.29
|
||||
swr: ^1.2.0
|
||||
tippy.js: ^6.3.7
|
||||
tsconfig-paths-webpack-plugin: ^3.5.2
|
||||
|
@ -157,6 +162,7 @@ importers:
|
|||
'@tiptap/extension-text-style': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171
|
||||
'@tiptap/extension-underline': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171
|
||||
'@tiptap/react': 2.0.0-beta.107_a3fcdb91535fe17b69dfabaa94f3bb3d
|
||||
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
|
||||
'@traptitech/markdown-it-katex': 3.5.0
|
||||
axios: 0.25.0
|
||||
classnames: 2.3.1
|
||||
|
@ -168,6 +174,8 @@ importers:
|
|||
lowlight: 2.5.0
|
||||
markdown-it: 12.3.2
|
||||
markdown-it-anchor: 8.4.1_markdown-it@12.3.2
|
||||
markdown-it-container: 3.0.0
|
||||
markdown-it-emoji: 2.0.0
|
||||
markdown-it-footnote: 3.0.3
|
||||
markdown-it-sub: 1.0.0
|
||||
markdown-it-sup: 1.0.0
|
||||
|
@ -175,11 +183,13 @@ importers:
|
|||
marked: 4.0.12
|
||||
next: 12.0.10_react-dom@17.0.2+react@17.0.2
|
||||
prosemirror-markdown: 1.7.0
|
||||
prosemirror-utils: 0.9.6
|
||||
prosemirror-view: 1.23.6
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2_react@17.0.2
|
||||
react-helmet: 6.1.0_react@17.0.2
|
||||
react-split-pane: 0.1.92_react-dom@17.0.2+react@17.0.2
|
||||
scroll-into-view-if-needed: 2.2.29
|
||||
swr: 1.2.0_react@17.0.2
|
||||
tippy.js: 6.3.7
|
||||
devDependencies:
|
||||
|
@ -792,7 +802,7 @@ packages:
|
|||
date-fns-tz: 1.2.2_date-fns@2.28.0
|
||||
lodash: 4.17.21
|
||||
memoize-one: 5.2.1
|
||||
scroll-into-view-if-needed: 2.2.28
|
||||
scroll-into-view-if-needed: 2.2.29
|
||||
dev: false
|
||||
|
||||
/@douyinfe/semi-icons/2.3.1_react@17.0.2:
|
||||
|
@ -860,7 +870,7 @@ packages:
|
|||
react-sortable-hoc: 1.11.0_react-dom@17.0.2+react@17.0.2
|
||||
react-window: 1.8.6_react-dom@17.0.2+react@17.0.2
|
||||
resize-observer-polyfill: 1.5.1
|
||||
scroll-into-view-if-needed: 2.2.28
|
||||
scroll-into-view-if-needed: 2.2.29
|
||||
utility-types: 3.10.0
|
||||
dev: false
|
||||
|
||||
|
@ -1914,6 +1924,17 @@ packages:
|
|||
react-dom: 17.0.2_react@17.0.2
|
||||
dev: false
|
||||
|
||||
/@tiptap/suggestion/2.0.0-beta.90_@tiptap+core@2.0.0-beta.171:
|
||||
resolution: {integrity: sha512-L5PPYRatY/75uJJRQx2o/Ce+gzcOkmd81TwLjio9sADV3bRf4DO4WYcQy0AtGe6uNSz78DTL0SUVw4204VjoBw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.0.0-beta.1
|
||||
dependencies:
|
||||
'@tiptap/core': 2.0.0-beta.171
|
||||
prosemirror-model: 1.16.1
|
||||
prosemirror-state: 1.3.4
|
||||
prosemirror-view: 1.23.6
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once/1.1.2:
|
||||
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
@ -5958,6 +5979,14 @@ packages:
|
|||
markdown-it: 12.3.2
|
||||
dev: false
|
||||
|
||||
/markdown-it-container/3.0.0:
|
||||
resolution: {integrity: sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-emoji/2.0.0:
|
||||
resolution: {integrity: sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-footnote/3.0.3:
|
||||
resolution: {integrity: sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w==}
|
||||
dev: false
|
||||
|
@ -6811,6 +6840,14 @@ packages:
|
|||
prosemirror-model: 1.16.1
|
||||
dev: false
|
||||
|
||||
/prosemirror-utils/0.9.6:
|
||||
resolution: {integrity: sha512-UC+j9hQQ1POYfMc5p7UFxBTptRiGPR7Kkmbl3jVvU8VgQbkI89tR/GK+3QYC8n+VvBZrtAoCrJItNhWSxX3slA==}
|
||||
peerDependencies:
|
||||
prosemirror-model: ^1.0.0
|
||||
prosemirror-state: ^1.0.1
|
||||
prosemirror-tables: ^0.9.1
|
||||
dev: false
|
||||
|
||||
/prosemirror-view/1.23.6:
|
||||
resolution: {integrity: sha512-B4DAzriNpI/AVoW0Lu6SVfX00jZZQxOVwdBQEjWlRbCdT9V0pvk4GQJ3JTFaib+b6BcPdRZ3MjWXz2xvV1rblA==}
|
||||
dependencies:
|
||||
|
@ -7274,8 +7311,8 @@ packages:
|
|||
ajv: 6.12.6
|
||||
ajv-keywords: 3.5.2_ajv@6.12.6
|
||||
|
||||
/scroll-into-view-if-needed/2.2.28:
|
||||
resolution: {integrity: sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w==}
|
||||
/scroll-into-view-if-needed/2.2.29:
|
||||
resolution: {integrity: sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==}
|
||||
dependencies:
|
||||
compute-scroll-into-view: 1.0.17
|
||||
dev: false
|
||||
|
|
Loading…
Reference in New Issue