mirror of https://github.com/fantasticit/think.git
feat: improve tiptap
This commit is contained in:
parent
ea34e23422
commit
f68303720f
|
@ -7,6 +7,6 @@
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"printWidth": 100,
|
"printWidth": 120,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf"
|
||||||
}
|
}
|
|
@ -6,6 +6,7 @@ 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,
|
||||||
|
@ -44,6 +45,11 @@ 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: [
|
||||||
|
@ -52,6 +58,10 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
getCollaborationExtension(provider),
|
getCollaborationExtension(provider),
|
||||||
getCollaborationCursorExtension(provider, user),
|
getCollaborationCursorExtension(provider, user),
|
||||||
],
|
],
|
||||||
|
editorProps: {
|
||||||
|
// @ts-ignore
|
||||||
|
noTitleEditor,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const [loading, toggleLoading] = useToggle(true);
|
const [loading, toggleLoading] = useToggle(true);
|
||||||
|
|
||||||
|
|
|
@ -29,11 +29,11 @@ import { Italic } from './extensions/italic';
|
||||||
import { Katex } from './extensions/katex';
|
import { Katex } from './extensions/katex';
|
||||||
import { Link } from './extensions/link';
|
import { Link } from './extensions/link';
|
||||||
import { ListItem } from './extensions/listItem';
|
import { ListItem } from './extensions/listItem';
|
||||||
|
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 { PasteFile } from './extensions/pasteFile';
|
import { Paste } from './extensions/paste';
|
||||||
import { PasteMarkdown } from './extensions/pasteMarkdown';
|
|
||||||
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';
|
||||||
|
@ -83,11 +83,11 @@ export const BaseKit = [
|
||||||
Katex,
|
Katex,
|
||||||
Link,
|
Link,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
Loading,
|
||||||
Mind,
|
Mind,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
PasteFile,
|
Paste,
|
||||||
PasteMarkdown,
|
|
||||||
Placeholder,
|
Placeholder,
|
||||||
SearchNReplace,
|
SearchNReplace,
|
||||||
Status,
|
Status,
|
||||||
|
|
|
@ -1,27 +1,84 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button, Typography, Spin } from '@douyinfe/semi-ui';
|
||||||
import { IconDownload } from '@douyinfe/semi-icons';
|
import { IconDownload } from '@douyinfe/semi-icons';
|
||||||
import { Tooltip } from 'components/tooltip';
|
import { Tooltip } from 'components/tooltip';
|
||||||
|
import { useToggle } from 'hooks/useToggle';
|
||||||
import { download } from '../../services/download';
|
import { download } from '../../services/download';
|
||||||
|
import { uploadFile } from 'services/file';
|
||||||
|
import { normalizeFileSize, extractFileExtension, extractFilename } from '../../services/file';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
export const AttachmentWrapper = ({ node }) => {
|
const { Text } = Typography;
|
||||||
const { name, url } = node.attrs;
|
|
||||||
|
export const AttachmentWrapper = ({ node, updateAttributes }) => {
|
||||||
|
const $upload = useRef();
|
||||||
|
const { autoTrigger, fileName, fileSize, fileExt, fileType, url, error } = node.attrs;
|
||||||
|
const [loading, toggleLoading] = useToggle(false);
|
||||||
|
|
||||||
|
const selectFile = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
$upload.current.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = async (e) => {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
const fileInfo = {
|
||||||
|
fileName: extractFilename(file.name),
|
||||||
|
fileSize: file.size,
|
||||||
|
fileType: file.type,
|
||||||
|
fileExt: extractFileExtension(file.name),
|
||||||
|
};
|
||||||
|
toggleLoading(true);
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(file);
|
||||||
|
updateAttributes({ ...fileInfo, url });
|
||||||
|
toggleLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
updateAttributes({ error: '上传失败:' + (error && error.message) || '未知错误' });
|
||||||
|
toggleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url && !autoTrigger) {
|
||||||
|
selectFile();
|
||||||
|
updateAttributes({ autoTrigger: true });
|
||||||
|
}
|
||||||
|
}, [url, autoTrigger]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper as="div">
|
<NodeViewWrapper as="div">
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<span>{name}</span>
|
{!url ? (
|
||||||
<span>
|
error ? (
|
||||||
<Tooltip content="下载">
|
<Text>{error}</Text>
|
||||||
<Button
|
) : (
|
||||||
theme={'borderless'}
|
<Spin spinning={loading}>
|
||||||
type="tertiary"
|
<Text onClick={selectFile} style={{ cursor: 'pointer' }}>
|
||||||
icon={<IconDownload />}
|
{loading ? '正在上传中' : '请选择文件'}
|
||||||
onClick={() => download(url, name)}
|
</Text>
|
||||||
/>
|
<input ref={$upload} type="file" hidden onChange={handleFile} />
|
||||||
</Tooltip>
|
</Spin>
|
||||||
</span>
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{fileName}.{fileExt}
|
||||||
|
<Text type="tertiary"> ({normalizeFileSize(fileSize)})</Text>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Tooltip content="下载">
|
||||||
|
<Button
|
||||||
|
theme={'borderless'}
|
||||||
|
type="tertiary"
|
||||||
|
icon={<IconDownload />}
|
||||||
|
onClick={() => download(url, name)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NodeViewContent></NodeViewContent>
|
<NodeViewContent></NodeViewContent>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
|
@ -1,25 +1,89 @@
|
||||||
import { NodeViewWrapper } from '@tiptap/react';
|
import { NodeViewWrapper } from '@tiptap/react';
|
||||||
import { Resizeable } from 'components/resizeable';
|
import { Resizeable } from 'components/resizeable';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Typography, Spin } from '@douyinfe/semi-ui';
|
||||||
|
import { useToggle } from 'hooks/useToggle';
|
||||||
|
import { uploadFile } from 'services/file';
|
||||||
|
import { extractFileExtension, extractFilename } from '../../services/file';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
export const ImageWrapper = ({ editor, node, updateAttributes }) => {
|
export const ImageWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
const isEditable = editor.isEditable;
|
const isEditable = editor.isEditable;
|
||||||
const { src, alt, title, width, height, textAlign } = node.attrs;
|
const { autoTrigger, error, src, alt, title, width, height, textAlign } = node.attrs;
|
||||||
|
const $upload = useRef();
|
||||||
|
const [loading, toggleLoading] = useToggle(false);
|
||||||
|
|
||||||
const onResize = (size) => {
|
const onResize = (size) => {
|
||||||
updateAttributes({ height: size.height, width: size.width });
|
updateAttributes({ height: size.height, width: size.width });
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = src && <img src={src} alt={alt} width={width} height={height} />;
|
const selectFile = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
$upload.current.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = async (e) => {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
const fileInfo = {
|
||||||
|
fileName: extractFilename(file.name),
|
||||||
|
fileSize: file.size,
|
||||||
|
fileType: file.type,
|
||||||
|
fileExt: extractFileExtension(file.name),
|
||||||
|
};
|
||||||
|
toggleLoading(true);
|
||||||
|
try {
|
||||||
|
const src = await uploadFile(file);
|
||||||
|
updateAttributes({ ...fileInfo, src });
|
||||||
|
toggleLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
updateAttributes({ error: '上传失败:' + (error && error.message) || '未知错误' });
|
||||||
|
toggleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src && !autoTrigger) {
|
||||||
|
selectFile();
|
||||||
|
updateAttributes({ autoTrigger: true });
|
||||||
|
}
|
||||||
|
}, [src, autoTrigger]);
|
||||||
|
|
||||||
|
const content = (() => {
|
||||||
|
if (error) {
|
||||||
|
return <Text>{error}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Text onClick={selectFile} style={{ cursor: 'pointer' }}>
|
||||||
|
{loading ? '正在上传中' : '请选择图片'}
|
||||||
|
</Text>
|
||||||
|
<input ref={$upload} accept="image/*" type="file" hidden onChange={handleFile} />
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = <img src={src} alt={alt} width={width} height={height} />;
|
||||||
|
|
||||||
|
if (isEditable) {
|
||||||
|
return (
|
||||||
|
<Resizeable width={width} height={height} onChange={onResize}>
|
||||||
|
{img}
|
||||||
|
</Resizeable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{img}</div>;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
|
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
|
||||||
{isEditable ? (
|
{content}
|
||||||
<Resizeable width={width} height={height} onChange={onResize}>
|
|
||||||
{content}
|
|
||||||
</Resizeable>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{content}</div>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react';
|
||||||
|
import { Spin } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
export const LoadingWrapper = ({ editor, node, updateAttributes }) => {
|
||||||
|
const isEditable = editor.isEditable;
|
||||||
|
const { text } = node.attrs;
|
||||||
|
|
||||||
|
if (!isEditable) return <NodeViewWrapper />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="div">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '1em',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin tip={text ? `正在上传${text}中...` : ''} />
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
};
|
|
@ -12,6 +12,7 @@ interface IProps {
|
||||||
|
|
||||||
export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
const $container = useRef<HTMLDivElement>();
|
const $container = useRef<HTMLDivElement>();
|
||||||
|
const $image = useRef<HTMLInputElement>();
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
const selectItem = (index) => {
|
const selectItem = (index) => {
|
||||||
|
@ -34,6 +35,10 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
selectItem(selectedIndex);
|
selectItem(selectedIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectImage = function () {
|
||||||
|
console.log('image', this.files);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -2,6 +2,14 @@ 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';
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
attachment: {
|
||||||
|
setAttachment: (attrs?: unknown) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Attachment = Node.create({
|
export const Attachment = Node.create({
|
||||||
name: 'attachment',
|
name: 'attachment',
|
||||||
group: 'block',
|
group: 'block',
|
||||||
|
@ -26,19 +34,34 @@ export const Attachment = Node.create({
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
name: {
|
fileName: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
fileType: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
fileExt: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
url: {
|
url: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
autoTrigger: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
setAttachment:
|
setAttachment:
|
||||||
(attrs) =>
|
(attrs = {}) =>
|
||||||
({ chain }) => {
|
({ chain }) => {
|
||||||
return chain().insertContent({ type: this.name, attrs }).run();
|
return chain().insertContent({ type: this.name, attrs }).run();
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,9 +4,9 @@ import { BannerWrapper } from '../components/banner';
|
||||||
import { typesAvailable } from '../services/markdown/markdownBanner';
|
import { typesAvailable } from '../services/markdown/markdownBanner';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands {
|
interface Commands<ReturnType> {
|
||||||
banner: {
|
banner: {
|
||||||
setBanner: () => Command;
|
setBanner: (attrs) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Node, Command, 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';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands {
|
interface Commands<ReturnType> {
|
||||||
documentChildren: {
|
documentChildren: {
|
||||||
setDocumentChildren: () => Command;
|
setDocumentChildren: () => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,10 +35,7 @@ export const DocumentChildren = Node.create({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||||
'div',
|
|
||||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Node, Command, 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';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands {
|
interface Commands<ReturnType> {
|
||||||
documentReference: {
|
documentReference: {
|
||||||
setDocumentReference: () => Command;
|
setDocumentReference: () => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,10 +38,7 @@ export const DocumentReference = Node.create({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||||
'div',
|
|
||||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -7,6 +7,14 @@ import tippy from 'tippy.js';
|
||||||
import { EmojiList } from '../components/emojiList';
|
import { EmojiList } from '../components/emojiList';
|
||||||
import { emojiSearch, emojisToName } from '../components/emojiList/emojis';
|
import { emojiSearch, emojisToName } from '../components/emojiList/emojis';
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
emoji: {
|
||||||
|
setEmoji: (emoji: { name: string; emoji: string }) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const EmojiPluginKey = new PluginKey('emoji');
|
export const EmojiPluginKey = new PluginKey('emoji');
|
||||||
export { emojisToName };
|
export { emojisToName };
|
||||||
export const Emoji = Node.create({
|
export const Emoji = Node.create({
|
||||||
|
@ -30,10 +38,9 @@ export const Emoji = Node.create({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
emoji:
|
setEmoji:
|
||||||
(emojiObject) =>
|
(emojiObject) =>
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
return commands.insertContent(emojiObject.emoji + ' ');
|
return commands.insertContent(emojiObject.emoji + ' ');
|
||||||
|
@ -56,9 +63,7 @@ export const Emoji = Node.create({
|
||||||
decorations: (state) => {
|
decorations: (state) => {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return;
|
||||||
|
|
||||||
const parent = findParentNode((node) => node.type.name === 'paragraph')(
|
const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
|
||||||
state.selection
|
|
||||||
);
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,248 +4,11 @@ import { Plugin, PluginKey } from 'prosemirror-state';
|
||||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||||
import Suggestion from '@tiptap/suggestion';
|
import Suggestion from '@tiptap/suggestion';
|
||||||
import tippy from 'tippy.js';
|
import tippy from 'tippy.js';
|
||||||
import { Space } from '@douyinfe/semi-ui';
|
|
||||||
import { IconList, IconOrderedList } from '@douyinfe/semi-icons';
|
|
||||||
import {
|
|
||||||
IconLink,
|
|
||||||
IconQuote,
|
|
||||||
IconHorizontalRule,
|
|
||||||
IconTask,
|
|
||||||
IconDocument,
|
|
||||||
IconMind,
|
|
||||||
IconTable,
|
|
||||||
IconImage,
|
|
||||||
IconCodeBlock,
|
|
||||||
IconStatus,
|
|
||||||
IconInfo,
|
|
||||||
IconAttachment,
|
|
||||||
IconMath,
|
|
||||||
} from 'components/icons';
|
|
||||||
import { Upload } from 'components/upload';
|
|
||||||
import { MenuList } from '../components/menuList';
|
import { MenuList } from '../components/menuList';
|
||||||
import { getImageOriginSize } from '../services/image';
|
import { EVOKE_MENU_ITEMS } from '../menus/evokeMenu';
|
||||||
|
|
||||||
export const EvokeMenuPluginKey = new PluginKey('evokeMenu');
|
export const EvokeMenuPluginKey = new PluginKey('evokeMenu');
|
||||||
|
|
||||||
const COMMANDS = [
|
|
||||||
{
|
|
||||||
key: '标题1',
|
|
||||||
label: '标题1',
|
|
||||||
command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '标题1',
|
|
||||||
label: '标题2',
|
|
||||||
command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '标题1',
|
|
||||||
label: '标题3',
|
|
||||||
command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '标题1',
|
|
||||||
label: '标题4',
|
|
||||||
command: (editor) => editor.chain().focus().toggleHeading({ level: 4 }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '标题1',
|
|
||||||
label: '标题5',
|
|
||||||
command: (editor) => editor.chain().focus().toggleHeading({ level: 5 }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '标题1',
|
|
||||||
label: '标题6',
|
|
||||||
command: (editor) => editor.chain().focus().toggleHeading({ level: 6 }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '无序列表',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconList />
|
|
||||||
无序列表
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().toggleBulletList().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '有序列表',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconOrderedList />
|
|
||||||
有序列表
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '任务列表',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconTask />
|
|
||||||
任务列表
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().toggleTaskList().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '链接',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconLink />
|
|
||||||
链接
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().toggleLink().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '引用',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconQuote />
|
|
||||||
引用
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '分割线',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconHorizontalRule />
|
|
||||||
分割线
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '表格',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconTable />
|
|
||||||
表格
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) =>
|
|
||||||
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '代码块',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconCodeBlock />
|
|
||||||
代码块
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '图片',
|
|
||||||
label: (editor) => (
|
|
||||||
<Space>
|
|
||||||
<IconImage />
|
|
||||||
<Upload
|
|
||||||
accept="image/*"
|
|
||||||
onOK={async (url, fileName) => {
|
|
||||||
const { width, height } = await getImageOriginSize(url);
|
|
||||||
console.log('upload', width, height);
|
|
||||||
editor.chain().focus().setImage({ src: url, alt: fileName, width, height }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{() => '图片'}
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '附件',
|
|
||||||
label: (editor) => (
|
|
||||||
<Space>
|
|
||||||
<IconAttachment />
|
|
||||||
<Upload
|
|
||||||
onOK={(url, name) => {
|
|
||||||
editor.chain().focus().setAttachment({ url, name }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{() => '附件'}
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '外链',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconLink />
|
|
||||||
外链
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().insertIframe({ url: '' }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '思维导图',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconMind />
|
|
||||||
思维导图
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().insertMind().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '数学公式',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconMath />
|
|
||||||
数学公式
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().setKatex().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '状态',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconStatus />
|
|
||||||
状态
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().setStatus().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '信息框',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconInfo />
|
|
||||||
信息框
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().setBanner({ type: 'info' }).run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '文档',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconDocument />
|
|
||||||
文档
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().setDocumentReference().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '子文档',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<IconDocument />
|
|
||||||
子文档
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
command: (editor) => editor.chain().focus().setDocumentChildren().run(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const EvokeMenu = Node.create({
|
export const EvokeMenu = Node.create({
|
||||||
name: 'evokeMenu',
|
name: 'evokeMenu',
|
||||||
|
|
||||||
|
@ -261,7 +24,7 @@ export const EvokeMenu = Node.create({
|
||||||
const tr = state.tr.deleteRange($from.start(), $from.pos);
|
const tr = state.tr.deleteRange($from.start(), $from.pos);
|
||||||
dispatch(tr);
|
dispatch(tr);
|
||||||
props?.command(editor);
|
props?.command(editor);
|
||||||
editor.view.focus();
|
editor?.view?.focus();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -282,9 +45,7 @@ export const EvokeMenu = Node.create({
|
||||||
decorations: (state) => {
|
decorations: (state) => {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return;
|
||||||
|
|
||||||
const parent = findParentNode((node) => node.type.name === 'paragraph')(
|
const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
|
||||||
state.selection
|
|
||||||
);
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -324,7 +85,7 @@ export const EvokeMenu = Node.create({
|
||||||
}).configure({
|
}).configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: ({ query }) => {
|
items: ({ query }) => {
|
||||||
return COMMANDS.filter((command) => command.key.startsWith(query));
|
return EVOKE_MENU_ITEMS.filter((command) => command.key.startsWith(query));
|
||||||
},
|
},
|
||||||
render: () => {
|
render: () => {
|
||||||
let component;
|
let component;
|
||||||
|
|
|
@ -2,6 +2,14 @@ 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';
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iframe: {
|
||||||
|
setIframe: (attrs) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Iframe = Node.create({
|
export const Iframe = Node.create({
|
||||||
name: 'external-iframe',
|
name: 'external-iframe',
|
||||||
content: '',
|
content: '',
|
||||||
|
@ -47,14 +55,15 @@ export const Iframe = Node.create({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertIframe:
|
setIframe:
|
||||||
(options) =>
|
(options) =>
|
||||||
({ tr, commands, chain, editor }) => {
|
({ tr, commands, chain, editor }) => {
|
||||||
|
// @ts-ignore
|
||||||
if (tr.selection?.node?.type?.name == this.name) {
|
if (tr.selection?.node?.type?.name == this.name) {
|
||||||
return commands.updateAttributes(this.name, options);
|
return commands.updateAttributes(this.name, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url } = options || {};
|
const { url } = options || { url: '' };
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const pos = selection.$head;
|
const pos = selection.$head;
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,15 @@ import { Image as BuiltInImage } from '@tiptap/extension-image';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { ImageWrapper } from '../components/image';
|
import { ImageWrapper } from '../components/image';
|
||||||
|
|
||||||
const resolveImageEl = (element) =>
|
const resolveImageEl = (element) => (element.nodeName === 'IMG' ? element : element.querySelector('img'));
|
||||||
element.nodeName === 'IMG' ? element : element.querySelector('img');
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iamge: {
|
||||||
|
setEmptyImage: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Image = BuiltInImage.extend({
|
export const Image = BuiltInImage.extend({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
|
@ -19,7 +26,6 @@ export const Image = BuiltInImage.extend({
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const img = resolveImageEl(element);
|
const img = resolveImageEl(element);
|
||||||
|
|
||||||
return img.dataset.src || img.getAttribute('src');
|
return img.dataset.src || img.getAttribute('src');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -40,6 +46,22 @@ export const Image = BuiltInImage.extend({
|
||||||
height: {
|
height: {
|
||||||
default: 'auto',
|
default: 'auto',
|
||||||
},
|
},
|
||||||
|
autoTrigger: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
setEmptyImage:
|
||||||
|
(attrs = {}) =>
|
||||||
|
({ chain }) => {
|
||||||
|
return chain().insertContent({ type: this.name, attrs }).run();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Node, Command, mergeAttributes, nodeInputRule } from '@tiptap/core';
|
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import { KatexWrapper } from '../components/katex';
|
import { KatexWrapper } from '../components/katex';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands {
|
interface Commands<ReturnType> {
|
||||||
katex: {
|
katex: {
|
||||||
setKatex: () => Command;
|
setKatex: () => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,10 +33,7 @@ export const Katex = Node.create({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||||
'div',
|
|
||||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Node } from '@tiptap/core';
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import { LoadingWrapper } from '../components/loading';
|
||||||
|
|
||||||
|
export const Loading = Node.create({
|
||||||
|
name: 'loading',
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
text: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(LoadingWrapper);
|
||||||
|
},
|
||||||
|
});
|
|
@ -12,6 +12,14 @@ const DEFAULT_MIND_DATA = {
|
||||||
data: { id: 'root', topic: '中心节点', children: [] },
|
data: { id: 'root', topic: '中心节点', children: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
mind: {
|
||||||
|
setMind: (attrs?: unknown) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Mind = Node.create({
|
export const Mind = Node.create({
|
||||||
name: 'jsmind',
|
name: 'jsmind',
|
||||||
content: '',
|
content: '',
|
||||||
|
@ -57,9 +65,10 @@ export const Mind = Node.create({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertMind:
|
setMind:
|
||||||
(options) =>
|
(options) =>
|
||||||
({ tr, commands, chain, editor }) => {
|
({ tr, commands, chain, editor }) => {
|
||||||
|
// @ts-ignore
|
||||||
if (tr.selection?.node?.type?.name == this.name) {
|
if (tr.selection?.node?.type?.name == this.name) {
|
||||||
return commands.updateAttributes(this.name, options);
|
return commands.updateAttributes(this.name, options);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,90 +1,37 @@
|
||||||
import { Extension } from '@tiptap/core';
|
import { Extension } from '@tiptap/core';
|
||||||
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
|
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||||
// @ts-ignore
|
|
||||||
import { lowlight } from 'lowlight';
|
|
||||||
import { markdownSerializer } from '../services/markdown';
|
import { markdownSerializer } from '../services/markdown';
|
||||||
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
|
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
|
||||||
|
import { handleFileEvent } from '../services/upload';
|
||||||
|
import { isInCode, LANGUAGES } from '../services/code';
|
||||||
|
import { isMarkdown, normalizePastedMarkdown } from '../services/markdown/helpers';
|
||||||
|
|
||||||
const isMarkActive =
|
export const Paste = Extension.create({
|
||||||
(type) =>
|
name: 'paste',
|
||||||
(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,
|
priority: EXTENSION_PRIORITY_HIGHEST,
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
const { editor } = this;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: new PluginKey('pasteMarkdown'),
|
key: new PluginKey('paste'),
|
||||||
props: {
|
props: {
|
||||||
// @ts-ignore
|
handlePaste: (view, event: ClipboardEvent) => {
|
||||||
handlePaste: async (view, event: ClipboardEvent) => {
|
|
||||||
if (view.props.editable && !view.props.editable(view.state)) {
|
if (view.props.editable && !view.props.editable(view.state)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!event.clipboardData) return false;
|
if (!event.clipboardData) return false;
|
||||||
|
|
||||||
|
const files = Array.from(event.clipboardData.files);
|
||||||
|
|
||||||
|
if (files.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
files.forEach((file) => {
|
||||||
|
handleFileEvent({ editor, file });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const text = event.clipboardData.getData('text/plain');
|
const text = event.clipboardData.getData('text/plain');
|
||||||
const html = event.clipboardData.getData('text/html');
|
const html = event.clipboardData.getData('text/html');
|
||||||
const vscode = event.clipboardData.getData('vscode-editor-data');
|
const vscode = event.clipboardData.getData('vscode-editor-data');
|
||||||
|
@ -104,9 +51,7 @@ export const PasteMarkdown = Extension.create({
|
||||||
view.dispatch(
|
view.dispatch(
|
||||||
view.state.tr.replaceSelectionWith(
|
view.state.tr.replaceSelectionWith(
|
||||||
view.state.schema.nodes.codeBlock.create({
|
view.state.schema.nodes.codeBlock.create({
|
||||||
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
|
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode) ? vscodeMeta.mode : null,
|
||||||
? vscodeMeta.mode
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -117,16 +62,46 @@ export const PasteMarkdown = Extension.create({
|
||||||
// 处理 markdown
|
// 处理 markdown
|
||||||
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const paste = markdownSerializer.deserialize({
|
// FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在
|
||||||
schema: view.props.state.schema,
|
// const hasTitle = isTitleNode(view.props.state.doc.content.firstChild);
|
||||||
|
let schema = view.props.state.schema;
|
||||||
|
const doc = markdownSerializer.deserialize({
|
||||||
|
schema,
|
||||||
content: normalizePastedMarkdown(text),
|
content: normalizePastedMarkdown(text),
|
||||||
});
|
});
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const transaction = view.state.tr.replaceSelectionWith(paste);
|
const transaction = view.state.tr.insert(view.state.selection.head, doc);
|
||||||
view.dispatch(transaction);
|
view.dispatch(transaction);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (text.length !== 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
view.dispatch(view.state.tr.insertText(text));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
handleDrop: (view, event: any) => {
|
||||||
|
if (view.props.editable && !view.props.editable(view.state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFiles = event.dataTransfer.files.length > 0;
|
||||||
|
if (!hasFiles) return false;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
if (files.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
files.forEach((file: File) => {
|
||||||
|
handleFileEvent({ editor, file });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
clipboardTextSerializer: (slice) => {
|
clipboardTextSerializer: (slice) => {
|
|
@ -1,83 +0,0 @@
|
||||||
import { Plugin } from 'prosemirror-state';
|
|
||||||
import { Extension } from '@tiptap/core';
|
|
||||||
import { uploadFile } from 'services/file';
|
|
||||||
import { Attachment } from './attachment';
|
|
||||||
import { Image } from './image';
|
|
||||||
|
|
||||||
export const acceptedMimes = {
|
|
||||||
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PasteFile = Extension.create({
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
// @ts-ignore
|
|
||||||
handlePaste: async (view, event: ClipboardEvent) => {
|
|
||||||
if (view.props.editable && !view.props.editable(view.state)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!event.clipboardData) return false;
|
|
||||||
|
|
||||||
const file = event.clipboardData.files[0];
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
event.preventDefault();
|
|
||||||
const url = await uploadFile(file);
|
|
||||||
let node = null;
|
|
||||||
if (acceptedMimes.image.includes(file?.type)) {
|
|
||||||
node = view.props.state.schema.nodes[Image.name].create({
|
|
||||||
src: url,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
node = view.props.state.schema.nodes[Attachment.name].create({
|
|
||||||
url,
|
|
||||||
name: file.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const transaction = view.state.tr.replaceSelectionWith(node);
|
|
||||||
view.dispatch(transaction);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
// @ts-ignore
|
|
||||||
handleDrop: async (view, event: any) => {
|
|
||||||
if (view.props.editable && !view.props.editable(view.state)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFiles = event.dataTransfer.files.length > 0;
|
|
||||||
if (!hasFiles) return false;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const files = Array.from(event.dataTransfer.files);
|
|
||||||
files.forEach(async (file: any) => {
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = await uploadFile(file);
|
|
||||||
let node = null;
|
|
||||||
if (acceptedMimes.image.includes(file?.type)) {
|
|
||||||
node = view.props.state.schema.nodes[Image.name].create({
|
|
||||||
src: url,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
node = view.props.state.schema.nodes[Attachment.name].create({
|
|
||||||
url,
|
|
||||||
name: file.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const transaction = view.state.tr.replaceSelectionWith(node);
|
|
||||||
view.dispatch(transaction);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -2,6 +2,7 @@ import { Extension } from '@tiptap/core';
|
||||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||||
import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
|
import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
|
||||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||||
|
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -193,7 +194,7 @@ const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, g
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = window.document.querySelector(`.${searchResultCurrentClass}`);
|
const el = window.document.querySelector(`.${searchResultCurrentClass}`);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' });
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
@ -207,15 +208,17 @@ const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, g
|
||||||
export const SearchNReplace = Extension.create<SearchOptions>({
|
export const SearchNReplace = Extension.create<SearchOptions>({
|
||||||
name: 'search',
|
name: 'search',
|
||||||
|
|
||||||
defaultOptions: {
|
addOptions() {
|
||||||
searchTerm: '',
|
return {
|
||||||
replaceTerm: '',
|
searchTerm: '',
|
||||||
results: [],
|
replaceTerm: '',
|
||||||
currentIndex: 0,
|
results: [],
|
||||||
searchResultClass: 'search-result',
|
currentIndex: 0,
|
||||||
searchResultCurrentClass: 'search-result-current',
|
searchResultClass: 'search-result',
|
||||||
caseSensitive: false,
|
searchResultCurrentClass: 'search-result-current',
|
||||||
disableRegex: false,
|
caseSensitive: false,
|
||||||
|
disableRegex: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Node, Command, 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';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands {
|
interface Commands<ReturnType> {
|
||||||
status: {
|
status: {
|
||||||
setStatus: () => Command;
|
setStatus: () => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,10 +33,7 @@ export const Status = Node.create({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||||
'span',
|
|
||||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,14 +1,5 @@
|
||||||
import { Table as BuiltInTable } from '@tiptap/extension-table';
|
import { Table as BuiltInTable } from '@tiptap/extension-table';
|
||||||
import { TableView } from '../views/tableView';
|
|
||||||
|
|
||||||
export const Table = BuiltInTable.extend({
|
export const Table = BuiltInTable.configure({
|
||||||
// @ts-ignore
|
resizable: false,
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
View: TableView,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}).configure({
|
|
||||||
resizable: true,
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
selectRow,
|
selectRow,
|
||||||
selectTable,
|
selectTable,
|
||||||
} from '../services/table';
|
} from '../services/table';
|
||||||
import { elementInViewport } from '../services/dom';
|
|
||||||
import { FloatMenuView } from '../views/floatMenuView';
|
import { FloatMenuView } from '../views/floatMenuView';
|
||||||
|
|
||||||
export const TableCell = BuiltInTableCell.extend({
|
export const TableCell = BuiltInTableCell.extend({
|
||||||
|
@ -27,27 +26,27 @@ export const TableCell = BuiltInTableCell.extend({
|
||||||
view: () =>
|
view: () =>
|
||||||
new FloatMenuView({
|
new FloatMenuView({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
|
tippyOptions: {
|
||||||
|
zIndex: 10000,
|
||||||
|
offset: [-28, 0],
|
||||||
|
},
|
||||||
shouldShow: ({ editor }, floatMenuView) => {
|
shouldShow: ({ editor }, floatMenuView) => {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isTableSelected(editor.state.selection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const cells = getCellsInColumn(0)(editor.state.selection);
|
const cells = getCellsInColumn(0)(editor.state.selection);
|
||||||
if (selectedRowIndex > -1) {
|
if (selectedRowIndex > -1) {
|
||||||
|
// 获取当前行的第一个单元格的位置
|
||||||
const rowCells = getCellsInRow(selectedRowIndex)(editor.state.selection);
|
const rowCells = getCellsInRow(selectedRowIndex)(editor.state.selection);
|
||||||
if (rowCells && rowCells[0]) {
|
if (rowCells && rowCells[0]) {
|
||||||
const node = editor.view.nodeDOM(rowCells[0].pos) as HTMLElement;
|
const node = editor.view.nodeDOM(rowCells[0].pos) as HTMLElement;
|
||||||
if (node) {
|
if (node) {
|
||||||
const el = node.querySelector('a.grip-row') as HTMLElement;
|
const el = node.querySelector('a.grip-row') as HTMLElement;
|
||||||
if (el) {
|
if (el) {
|
||||||
console.log({ el });
|
|
||||||
floatMenuView.parentNode = el;
|
floatMenuView.parentNode = el;
|
||||||
// const intersectionObserver = new IntersectionObserver(function (entries) {
|
|
||||||
// console.log('ob');
|
|
||||||
// if (entries[0].intersectionRatio <= 0) {
|
|
||||||
// floatMenuView.hide();
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// intersectionObserver.observe(el);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,9 @@ export const TableHeader = BuiltInTableHeader.extend({
|
||||||
view: () =>
|
view: () =>
|
||||||
new FloatMenuView({
|
new FloatMenuView({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
|
tippyOptions: {
|
||||||
|
zIndex: 10000,
|
||||||
|
},
|
||||||
shouldShow: ({ editor }) => {
|
shouldShow: ({ editor }) => {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -44,7 +47,7 @@ export const TableHeader = BuiltInTableHeader.extend({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="向后插入一列">
|
<Tooltip content="删除当前列">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
|
@ -55,7 +58,7 @@ export const TableHeader = BuiltInTableHeader.extend({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="删除当前列" hideOnClick>
|
<Tooltip content="向后插入一列" hideOnClick>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
|
||||||
export const Title = Node.create({
|
export const Title = Node.create({
|
||||||
name: 'title',
|
name: 'title',
|
||||||
content: 'inline*',
|
content: 'text*',
|
||||||
selectable: true,
|
selectable: true,
|
||||||
defining: true,
|
defining: true,
|
||||||
inline: false,
|
inline: false,
|
||||||
|
|
|
@ -22,7 +22,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<Space spacing={2}>
|
<Space spacing={2}>
|
||||||
<MediaInsertMenu editor={editor} />
|
<MediaInsertMenu editor={editor} />
|
||||||
|
|
||||||
|
@ -77,7 +77,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
|
||||||
<LinkBubbleMenu editor={editor} />
|
<LinkBubbleMenu editor={editor} />
|
||||||
<BannerBubbleMenu editor={editor} />
|
<BannerBubbleMenu editor={editor} />
|
||||||
<TableBubbleMenu editor={editor} />
|
<TableBubbleMenu editor={editor} />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { Space } from '@douyinfe/semi-ui';
|
||||||
|
import { IconList, IconOrderedList } from '@douyinfe/semi-icons';
|
||||||
|
import {
|
||||||
|
IconLink,
|
||||||
|
IconQuote,
|
||||||
|
IconHorizontalRule,
|
||||||
|
IconTask,
|
||||||
|
IconDocument,
|
||||||
|
IconMind,
|
||||||
|
IconTable,
|
||||||
|
IconImage,
|
||||||
|
IconCodeBlock,
|
||||||
|
IconStatus,
|
||||||
|
IconInfo,
|
||||||
|
IconAttachment,
|
||||||
|
IconMath,
|
||||||
|
} from 'components/icons';
|
||||||
|
|
||||||
|
export const EVOKE_MENU_ITEMS = [
|
||||||
|
{
|
||||||
|
key: '标题1',
|
||||||
|
label: '标题1',
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '标题1',
|
||||||
|
label: '标题2',
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '标题1',
|
||||||
|
label: '标题3',
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '标题1',
|
||||||
|
label: '标题4',
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 4 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '标题1',
|
||||||
|
label: '标题5',
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 5 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '标题1',
|
||||||
|
label: '标题6',
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 6 }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '无序列表',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconList />
|
||||||
|
无序列表
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '有序列表',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconOrderedList />
|
||||||
|
有序列表
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '任务列表',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconTask />
|
||||||
|
任务列表
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleTaskList().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '链接',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconLink />
|
||||||
|
链接
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleLink({ href: '' }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '引用',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconQuote />
|
||||||
|
引用
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleBlockquote().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '分割线',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconHorizontalRule />
|
||||||
|
分割线
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '表格',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconTable />
|
||||||
|
表格
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '代码块',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconCodeBlock />
|
||||||
|
代码块
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '图片',
|
||||||
|
label: () => (
|
||||||
|
<Space>
|
||||||
|
<IconImage />
|
||||||
|
图片
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setEmptyImage().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '附件',
|
||||||
|
label: () => (
|
||||||
|
<Space>
|
||||||
|
<IconAttachment />
|
||||||
|
附件
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setAttachment().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '外链',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconLink />
|
||||||
|
外链
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setIframe({ url: '' }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '思维导图',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconMind />
|
||||||
|
思维导图
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setMind().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '数学公式',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconMath />
|
||||||
|
数学公式
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setKatex().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '状态',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconStatus />
|
||||||
|
状态
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setStatus().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '信息框',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconInfo />
|
||||||
|
信息框
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setBanner({ type: 'info' }).run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '文档',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconDocument />
|
||||||
|
文档
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setDocumentReference().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '子文档',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<IconDocument />
|
||||||
|
子文档
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
command: (editor: Editor) => editor.chain().focus().setDocumentChildren().run(),
|
||||||
|
},
|
||||||
|
];
|
|
@ -1,12 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui';
|
import { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui';
|
||||||
import {
|
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconUpload, IconDelete } from '@douyinfe/semi-icons';
|
||||||
IconAlignLeft,
|
|
||||||
IconAlignCenter,
|
|
||||||
IconAlignRight,
|
|
||||||
IconUpload,
|
|
||||||
IconDelete,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import { Tooltip } from 'components/tooltip';
|
import { Tooltip } from 'components/tooltip';
|
||||||
import { Upload } from 'components/upload';
|
import { Upload } from 'components/upload';
|
||||||
import { BubbleMenu } from './components/bubbleMenu';
|
import { BubbleMenu } from './components/bubbleMenu';
|
||||||
|
@ -27,12 +21,14 @@ export const ImageBubbleMenu = ({ editor }) => {
|
||||||
setHeight(parseInt(currentHeight));
|
setHeight(parseInt(currentHeight));
|
||||||
}, [currentWidth, currentHeight]);
|
}, [currentWidth, currentHeight]);
|
||||||
|
|
||||||
|
console.log(attrs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
className={'bubble-menu'}
|
className={'bubble-menu'}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="image-bubble-menu"
|
pluginKey="image-bubble-menu"
|
||||||
shouldShow={() => editor.isActive(Image.name)}
|
shouldShow={() => editor.isActive(Image.name) && !!attrs.src}
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
maxWidth: 456,
|
maxWidth: 456,
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
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 { Upload } from './components/upload';
|
||||||
import {
|
import {
|
||||||
IconDocument,
|
IconDocument,
|
||||||
IconMind,
|
IconMind,
|
||||||
|
@ -17,9 +18,9 @@ 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 { getImageOriginSize } from '../services/image';
|
import { handleFileEvent } from '../services/upload';
|
||||||
|
|
||||||
export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
export const MediaInsertMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -45,11 +46,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||||
<div style={{ padding: 0 }}>
|
<div style={{ padding: 0 }}>
|
||||||
<GridSelect
|
<GridSelect
|
||||||
onSelect={({ rows, cols }) => {
|
onSelect={({ rows, cols }) => {
|
||||||
return editor
|
return editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertTable({ rows, cols, withHeaderRow: true })
|
|
||||||
.run();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -63,33 +60,20 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||||
<Dropdown.Item onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
<Dropdown.Item onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||||
<IconCodeBlock /> 代码块
|
<IconCodeBlock /> 代码块
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item>
|
|
||||||
|
<Dropdown.Item onClick={() => editor.chain().focus().setEmptyImage().run()}>
|
||||||
<IconImage />
|
<IconImage />
|
||||||
<Upload
|
图片
|
||||||
accept="image/*"
|
|
||||||
onOK={async (url, fileName) => {
|
|
||||||
const { width, height } = await getImageOriginSize(url);
|
|
||||||
console.log('upload', width, height);
|
|
||||||
editor.chain().focus().setImage({ src: url, alt: fileName, width, height }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{() => '图片'}
|
|
||||||
</Upload>
|
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item>
|
<Dropdown.Item onClick={() => editor.chain().focus().setAttachment().run()}>
|
||||||
<IconAttachment />
|
<IconAttachment />
|
||||||
<Upload
|
附件
|
||||||
onOK={(url, name) => {
|
|
||||||
editor.chain().focus().setAttachment({ url, name }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{() => '附件'}
|
|
||||||
</Upload>
|
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item onClick={() => editor.chain().focus().insertIframe({ url: '' }).run()}>
|
|
||||||
|
<Dropdown.Item onClick={() => editor.chain().focus().setIframe({ url: '' }).run()}>
|
||||||
<IconLink /> 外链
|
<IconLink /> 外链
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item onClick={() => editor.chain().focus().insertMind().run()}>
|
<Dropdown.Item onClick={() => editor.chain().focus().setMind().run()}>
|
||||||
<IconMind /> 思维导图
|
<IconMind /> 思维导图
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
@ -119,12 +103,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Tooltip content="插入">
|
<Tooltip content="插入">
|
||||||
<Button
|
<Button type="tertiary" theme="borderless" icon={<IconPlus />} disabled={isTitleActive(editor)} />
|
||||||
type="tertiary"
|
|
||||||
theme="borderless"
|
|
||||||
icon={<IconPlus />}
|
|
||||||
disabled={isTitleActive(editor)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
IconDeleteTable,
|
IconDeleteTable,
|
||||||
} from 'components/icons';
|
} from 'components/icons';
|
||||||
import { Tooltip } from 'components/tooltip';
|
import { Tooltip } from 'components/tooltip';
|
||||||
|
import { Divider } from '../components/divider';
|
||||||
import { BubbleMenu } from './components/bubbleMenu';
|
import { BubbleMenu } from './components/bubbleMenu';
|
||||||
import { Table } from '../extensions/table';
|
import { Table } from '../extensions/table';
|
||||||
|
|
||||||
|
@ -24,9 +25,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
maxWidth: 456,
|
maxWidth: 456,
|
||||||
}}
|
}}
|
||||||
matchRenderContainer={(node: HTMLElement) =>
|
matchRenderContainer={(node: HTMLElement) => node.classList && node.tagName === 'TABLE'}
|
||||||
node.classList && node.classList.contains('tableWrapper')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip content="向前插入一列">
|
<Tooltip content="向前插入一列">
|
||||||
|
@ -58,6 +57,8 @@ export const TableBubbleMenu = ({ editor }) => {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<Tooltip content="向前插入一行">
|
<Tooltip content="向前插入一行">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => editor.chain().focus().addRowBefore().run()}
|
onClick={() => editor.chain().focus().addRowBefore().run()}
|
||||||
|
@ -88,6 +89,8 @@ export const TableBubbleMenu = ({ editor }) => {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<Tooltip content="合并单元格">
|
<Tooltip content="合并单元格">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
@ -108,6 +111,8 @@ export const TableBubbleMenu = ({ editor }) => {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<Tooltip content="删除表格" hideOnClick>
|
<Tooltip content="删除表格" hideOnClick>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { EditorState } from 'prosemirror-state';
|
||||||
|
// @ts-ignore
|
||||||
|
import { lowlight } from 'lowlight';
|
||||||
|
import { isMarkActive } from './isActive';
|
||||||
|
|
||||||
|
export const LANGUAGES = lowlight.listLanguages().reduce((a, language) => {
|
||||||
|
a[language] = language;
|
||||||
|
return a;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* 获取文件名
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* > extractFilename('https://gitlab.com/images/logo-full.png')
|
||||||
|
* < 'logo-full'
|
||||||
|
*
|
||||||
|
* @param {string} src The URL to extract filename from
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const extractFilename = (src) => {
|
||||||
|
return src.replace(/^.*\/|\..+?$/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件扩展名
|
||||||
|
* @param {string} fileName 文件名
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const extractFileExtension = (fileName) => {
|
||||||
|
return fileName.split('.').pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readFileAsDataURL = (file) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeFileSize = (size) => {
|
||||||
|
if (size < 1024) {
|
||||||
|
return size + ' Byte';
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return (size / 1024).toFixed(2) + ' KB';
|
||||||
|
}
|
||||||
|
return (size / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
};
|
|
@ -1,7 +1,19 @@
|
||||||
|
import { EditorState } from 'prosemirror-state';
|
||||||
|
|
||||||
export const isListActive = (editor) => {
|
export const isListActive = (editor) => {
|
||||||
return (
|
return editor.isActive('bulletList') || editor.isActive('orderedList') || editor.isActive('taskList');
|
||||||
editor.isActive('bulletList') || editor.isActive('orderedList') || editor.isActive('taskList')
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isTitleActive = (editor) => editor.isActive('title');
|
export const isTitleActive = (editor) => editor.isActive('title');
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
export const isMarkdown = (text: string): boolean => {
|
||||||
|
// table
|
||||||
|
const tables = text.match(/^\|(\S)*\|/gm);
|
||||||
|
if (tables && tables.length) return true;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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;
|
||||||
|
};
|
|
@ -1,9 +1,6 @@
|
||||||
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
|
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
|
||||||
import { sanitize } from 'dompurify';
|
import { sanitize } from 'dompurify';
|
||||||
import {
|
import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||||
MarkdownSerializer as ProseMirrorMarkdownSerializer,
|
|
||||||
defaultMarkdownSerializer,
|
|
||||||
} from 'prosemirror-markdown';
|
|
||||||
import { markdown } from '.';
|
import { markdown } from '.';
|
||||||
import { Attachment } from '../../extensions/attachment';
|
import { Attachment } from '../../extensions/attachment';
|
||||||
import { Banner } from '../../extensions/banner';
|
import { Banner } from '../../extensions/banner';
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { Node } from 'prosemirror-model';
|
import { Node } from 'prosemirror-model';
|
||||||
|
|
||||||
|
export function isTitleNode(node: Node): boolean {
|
||||||
|
return node.type.name === 'title';
|
||||||
|
}
|
||||||
|
|
||||||
export function isBulletListNode(node: Node): boolean {
|
export function isBulletListNode(node: Node): boolean {
|
||||||
return node.type.name === 'bulletList';
|
return node.type.name === 'bulletList';
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { uploadFile } from 'services/file';
|
||||||
|
import { Loading } from '../extensions/loading';
|
||||||
|
import { Attachment } from '../extensions/attachment';
|
||||||
|
import { Image } from '../extensions/image';
|
||||||
|
import { extractFileExtension, extractFilename } from './file';
|
||||||
|
|
||||||
|
export const acceptedMimes = {
|
||||||
|
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg', 'image/svg+xml'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileInfo = {
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileType: string;
|
||||||
|
fileExt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FnProps {
|
||||||
|
file: File;
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片
|
||||||
|
* @param param0
|
||||||
|
*/
|
||||||
|
const uploadImage = async ({ file, fileInfo, editor }: FnProps & { fileInfo: FileInfo }) => {
|
||||||
|
const { view } = editor;
|
||||||
|
const { state } = view;
|
||||||
|
const { from } = state.selection;
|
||||||
|
const loadingNode = view.props.state.schema.nodes[Loading.name].create({
|
||||||
|
text: fileInfo.fileName,
|
||||||
|
});
|
||||||
|
view.dispatch(view.state.tr.replaceSelectionWith(loadingNode));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(file);
|
||||||
|
const node = view.props.state.schema.nodes[Image.name].create({
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
const transaction = view.state.tr.replaceRangeWith(from, from + loadingNode.nodeSize, node);
|
||||||
|
view.dispatch(transaction);
|
||||||
|
} catch (e) {
|
||||||
|
editor.commands.deleteRange({ from: from, to: from + loadingNode.nodeSize });
|
||||||
|
console.log('上传文件失败!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传附件
|
||||||
|
* @param param0
|
||||||
|
*/
|
||||||
|
const uploadAttachment = async ({ file, fileInfo, editor }: FnProps & { fileInfo: FileInfo }) => {
|
||||||
|
const { view } = editor;
|
||||||
|
const { state } = view;
|
||||||
|
const { from } = state.selection;
|
||||||
|
const loadingNode = view.props.state.schema.nodes[Loading.name].create({
|
||||||
|
text: fileInfo.fileName,
|
||||||
|
});
|
||||||
|
view.dispatch(view.state.tr.replaceSelectionWith(loadingNode));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(file);
|
||||||
|
const node = view.props.state.schema.nodes[Attachment.name].create({
|
||||||
|
url,
|
||||||
|
...fileInfo,
|
||||||
|
});
|
||||||
|
const transaction = view.state.tr.replaceRangeWith(from, from + loadingNode.nodeSize, node);
|
||||||
|
view.dispatch(transaction);
|
||||||
|
} catch (e) {
|
||||||
|
editor.commands.deleteRange({ from: from, to: from + loadingNode.nodeSize });
|
||||||
|
console.log('上传文件失败!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleFileEvent = ({ file, editor }: FnProps) => {
|
||||||
|
if (!file) return false;
|
||||||
|
|
||||||
|
const fileInfo = {
|
||||||
|
fileName: extractFilename(file.name),
|
||||||
|
fileSize: file.size,
|
||||||
|
fileType: file.type,
|
||||||
|
fileExt: extractFileExtension(file.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (acceptedMimes.image.includes(file?.type)) {
|
||||||
|
uploadImage({ file, editor, fileInfo });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadAttachment({ file, editor, fileInfo });
|
||||||
|
return true;
|
||||||
|
};
|
|
@ -35,6 +35,7 @@ export class FloatMenuView {
|
||||||
private popup: Instance;
|
private popup: Instance;
|
||||||
private _update: FloatMenuViewOptions['update'];
|
private _update: FloatMenuViewOptions['update'];
|
||||||
private shouldShow: FloatMenuViewOptions['shouldShow'];
|
private shouldShow: FloatMenuViewOptions['shouldShow'];
|
||||||
|
private tippyOptions: FloatMenuViewOptions['tippyOptions'];
|
||||||
private getReferenceClientRect: NonNullable<FloatMenuViewOptions['getReferenceClientRect']> = ({
|
private getReferenceClientRect: NonNullable<FloatMenuViewOptions['getReferenceClientRect']> = ({
|
||||||
editor,
|
editor,
|
||||||
range,
|
range,
|
||||||
|
@ -47,12 +48,16 @@ export class FloatMenuView {
|
||||||
return node.getBoundingClientRect();
|
return node.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.parentNode) {
|
||||||
|
return this.parentNode.getBoundingClientRect();
|
||||||
|
}
|
||||||
return posToDOMRect(view, range.from, range.to);
|
return posToDOMRect(view, range.from, range.to);
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props: FloatMenuViewOptions) {
|
constructor(props: FloatMenuViewOptions) {
|
||||||
this.editor = props.editor;
|
this.editor = props.editor;
|
||||||
this.shouldShow = props.shouldShow;
|
this.shouldShow = props.shouldShow;
|
||||||
|
this.tippyOptions = props.tippyOptions;
|
||||||
if (props.getReferenceClientRect) {
|
if (props.getReferenceClientRect) {
|
||||||
this.getReferenceClientRect = props.getReferenceClientRect;
|
this.getReferenceClientRect = props.getReferenceClientRect;
|
||||||
}
|
}
|
||||||
|
@ -63,15 +68,25 @@ export class FloatMenuView {
|
||||||
props.init(this.dom, this.editor);
|
props.init(this.dom, this.editor);
|
||||||
|
|
||||||
// popup
|
// popup
|
||||||
this.popup = tippy(document.body, {
|
this.createPopup();
|
||||||
appendTo: () => document.body,
|
}
|
||||||
|
|
||||||
|
createPopup() {
|
||||||
|
const { element: editorElement } = this.editor.options;
|
||||||
|
const editorIsAttached = !!editorElement.parentElement;
|
||||||
|
|
||||||
|
if (this.popup || !editorIsAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.popup = tippy(editorElement, {
|
||||||
getReferenceClientRect: null,
|
getReferenceClientRect: null,
|
||||||
content: this.dom,
|
content: this.dom,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
hideOnClick: 'toggle',
|
hideOnClick: 'toggle',
|
||||||
...(props.tippyOptions ?? {}),
|
...(this.tippyOptions ?? {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +99,8 @@ export class FloatMenuView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.createPopup();
|
||||||
|
|
||||||
const { ranges } = selection;
|
const { ranges } = selection;
|
||||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||||
|
@ -118,10 +135,6 @@ export class FloatMenuView {
|
||||||
|
|
||||||
this.popup.setProps({
|
this.popup.setProps({
|
||||||
getReferenceClientRect: () => {
|
getReferenceClientRect: () => {
|
||||||
if (this.parentNode) {
|
|
||||||
return this.parentNode.getBoundingClientRect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getReferenceClientRect({
|
return this.getReferenceClientRect({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
oldState,
|
oldState,
|
||||||
|
@ -137,14 +150,14 @@ export class FloatMenuView {
|
||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.popup.show();
|
this.popup?.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
this.popup.hide();
|
this.popup?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.popup.destroy();
|
this.popup?.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,103 +0,0 @@
|
||||||
// @ts-nocheck
|
|
||||||
import { NodeView } from 'prosemirror-view';
|
|
||||||
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
|
||||||
|
|
||||||
export function updateColumns(
|
|
||||||
node: ProseMirrorNode,
|
|
||||||
colgroup: Element,
|
|
||||||
table: Element,
|
|
||||||
cellMinWidth: number,
|
|
||||||
overrideCol?: number,
|
|
||||||
overrideValue?: any
|
|
||||||
) {
|
|
||||||
let totalWidth = 0;
|
|
||||||
let fixedWidth = true;
|
|
||||||
let nextDOM = colgroup.firstChild;
|
|
||||||
const row = node.firstChild;
|
|
||||||
|
|
||||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
|
||||||
const { colspan, colwidth } = row.child(i).attrs;
|
|
||||||
|
|
||||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
|
||||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j];
|
|
||||||
const cssWidth = hasWidth ? `${hasWidth}px` : '';
|
|
||||||
|
|
||||||
totalWidth += hasWidth || cellMinWidth;
|
|
||||||
|
|
||||||
if (!hasWidth) {
|
|
||||||
fixedWidth = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextDOM) {
|
|
||||||
colgroup.appendChild(document.createElement('col')).style.width = cssWidth;
|
|
||||||
} else {
|
|
||||||
if (nextDOM.style.width !== cssWidth) {
|
|
||||||
nextDOM.style.width = cssWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextDOM = nextDOM.nextSibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (nextDOM) {
|
|
||||||
const after = nextDOM.nextSibling;
|
|
||||||
|
|
||||||
nextDOM.parentNode.removeChild(nextDOM);
|
|
||||||
nextDOM = after;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fixedWidth) {
|
|
||||||
table.style.width = `${totalWidth}px`;
|
|
||||||
table.style.minWidth = '';
|
|
||||||
} else {
|
|
||||||
table.style.width = '';
|
|
||||||
table.style.minWidth = `${totalWidth}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TableView implements NodeView {
|
|
||||||
node: ProseMirrorNode;
|
|
||||||
|
|
||||||
cellMinWidth: number;
|
|
||||||
|
|
||||||
dom: Element;
|
|
||||||
|
|
||||||
table: Element;
|
|
||||||
|
|
||||||
colgroup: Element;
|
|
||||||
|
|
||||||
contentDOM: Element;
|
|
||||||
|
|
||||||
constructor(node: ProseMirrorNode, cellMinWidth: number) {
|
|
||||||
this.node = node;
|
|
||||||
this.cellMinWidth = cellMinWidth;
|
|
||||||
this.dom = document.createElement('div');
|
|
||||||
this.dom.className = 'tableWrapper';
|
|
||||||
this.innerDom = document.createElement('div');
|
|
||||||
this.innerDom.className = 'tableInnerWrapper';
|
|
||||||
this.dom.appendChild(this.innerDom);
|
|
||||||
this.table = this.innerDom.appendChild(document.createElement('table'));
|
|
||||||
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
|
|
||||||
updateColumns(node, this.colgroup, this.table, cellMinWidth);
|
|
||||||
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
|
|
||||||
}
|
|
||||||
|
|
||||||
update(node: ProseMirrorNode) {
|
|
||||||
if (node.type !== this.node.type) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.node = node;
|
|
||||||
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
|
|
||||||
return (
|
|
||||||
mutation.type === 'attributes' &&
|
|
||||||
(mutation.target === this.table || this.colgroup.contains(mutation.target))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -8,7 +8,7 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
&.table-bubble-menu {
|
&.table-bubble-menu {
|
||||||
transform: translateY(-1em);
|
transform: translateY(-2em);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,13 +18,11 @@
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
background-color: var(--semi-color-nav-bg);
|
background-color: var(--semi-color-nav-bg);
|
||||||
transform: translateY(-1em);
|
|
||||||
|
|
||||||
&.row {
|
&.row {
|
||||||
column-gap: 8px;
|
column-gap: 8px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: translate(-100%, 75%);
|
transform: translate(0, 70%);
|
||||||
margin-left: -1.2em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -199,17 +199,8 @@ a {
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tooltip {
|
.semi-spin-wrapper {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
line-height: 20px;
|
flex-direction: column;
|
||||||
background-color: rgba(var(--semi-grey-7), 1) !important;
|
align-items: center;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -299,17 +299,19 @@
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
width: 100%;
|
min-width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
|
position: relative;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--semi-color-fill-2);
|
border-color: var(--semi-color-fill-2);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
position: relative;
|
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
|
@ -320,7 +322,6 @@
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectedCell {
|
.selectedCell {
|
||||||
|
@ -330,68 +331,55 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.grip-column {
|
.grip-column {
|
||||||
&::after {
|
position: absolute;
|
||||||
content: '';
|
z-index: 10000;
|
||||||
position: absolute;
|
display: block;
|
||||||
display: block;
|
width: 100%;
|
||||||
width: 100%;
|
height: 0.7em;
|
||||||
height: 0.7em;
|
left: 0;
|
||||||
left: 0;
|
top: -1em;
|
||||||
top: -1em;
|
margin-bottom: 3px;
|
||||||
margin-bottom: 3px;
|
cursor: pointer;
|
||||||
cursor: pointer;
|
background: #ced4da;
|
||||||
background: #ced4da;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::after {
|
&:hover,
|
||||||
background: var(--semi-color-info);
|
&.selected {
|
||||||
}
|
|
||||||
|
|
||||||
&.selected::after {
|
|
||||||
background: var(--semi-color-info);
|
background: var(--semi-color-info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grip-row {
|
.grip-row {
|
||||||
&::after {
|
position: absolute;
|
||||||
content: '';
|
z-index: 10000;
|
||||||
position: absolute;
|
display: block;
|
||||||
display: block;
|
height: 100%;
|
||||||
height: 100%;
|
width: 0.7em;
|
||||||
width: 0.7em;
|
top: 0;
|
||||||
top: 0;
|
left: -1em;
|
||||||
left: -1em;
|
margin-right: 3px;
|
||||||
margin-right: 3px;
|
cursor: pointer;
|
||||||
cursor: pointer;
|
background: #ced4da;
|
||||||
background: #ced4da;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::after {
|
&:hover,
|
||||||
background: var(--semi-color-info);
|
&.selected {
|
||||||
}
|
|
||||||
|
|
||||||
&.selected::after {
|
|
||||||
background: var(--semi-color-info);
|
background: var(--semi-color-info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grip-table {
|
.grip-table {
|
||||||
&::after {
|
position: absolute;
|
||||||
content: '';
|
z-index: 10000;
|
||||||
position: absolute;
|
display: block;
|
||||||
display: block;
|
width: 0.8em;
|
||||||
width: 0.8em;
|
height: 0.8em;
|
||||||
height: 0.8em;
|
top: -1em;
|
||||||
top: -1em;
|
left: -1em;
|
||||||
left: -1em;
|
border-radius: 50%;
|
||||||
border-radius: 50%;
|
cursor: pointer;
|
||||||
cursor: pointer;
|
background: #ced4da;
|
||||||
background: #ced4da;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::after {
|
&:hover,
|
||||||
background: var(--semi-color-info);
|
&.selected {
|
||||||
}
|
|
||||||
|
|
||||||
&.selected::after {
|
|
||||||
background: var(--semi-color-info);
|
background: var(--semi-color-info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +1,36 @@
|
||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
server:
|
server:
|
||||||
prefix: "/api"
|
prefix: '/api'
|
||||||
port: 5001
|
port: 5001
|
||||||
collaborationPort: 5003
|
collaborationPort: 5003
|
||||||
|
|
||||||
client:
|
client:
|
||||||
assetPrefix: "/"
|
assetPrefix: '/'
|
||||||
apiUrl: "http://localhost:5001/api"
|
apiUrl: 'http://localhost:5001/api'
|
||||||
collaborationUrl: "ws://localhost:5003"
|
collaborationUrl: 'ws://localhost:5003'
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
db:
|
db:
|
||||||
mysql:
|
mysql:
|
||||||
host: "127.0.0.1"
|
host: '127.0.0.1'
|
||||||
username: "root"
|
username: 'root'
|
||||||
password: "root"
|
password: 'root'
|
||||||
database: "think"
|
database: 'think'
|
||||||
port: 3306
|
port: 3306
|
||||||
charset: "utf8mb4"
|
charset: 'utf8mb4'
|
||||||
timezone: "+08:00"
|
timezone: '+08:00'
|
||||||
synchronize: true
|
synchronize: true
|
||||||
|
|
||||||
# oss 文件存储服务
|
# oss 文件存储服务
|
||||||
oss:
|
oss:
|
||||||
aliyun:
|
aliyun:
|
||||||
accessKeyId: "LTAI4Fc65yMwx2LR23PZwUed"
|
accessKeyId: 'LTAI5tNd19aX1TZJwGjKJWVE'
|
||||||
accessKeySecret: "I0EBFxRTWLyVk674raeYgC8h1tvSqG"
|
accessKeySecret: 'nlZJQprIO45QDeeANVlBZ6F7SIjBZs'
|
||||||
bucket: "wipi"
|
bucket: 'wipi'
|
||||||
https: true
|
https: true
|
||||||
region: "oss-cn-shanghai"
|
region: 'oss-cn-shanghai'
|
||||||
|
|
||||||
# jwt 配置
|
# jwt 配置
|
||||||
jwt:
|
jwt:
|
||||||
secretkey: "zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022"
|
secretkey: 'zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022'
|
||||||
expiresIn: "6h"
|
expiresIn: '6h'
|
||||||
|
|
|
@ -17,6 +17,7 @@ export class FileService {
|
||||||
* @param file
|
* @param file
|
||||||
*/
|
*/
|
||||||
async uploadFile(file) {
|
async uploadFile(file) {
|
||||||
|
console.log('upload', file);
|
||||||
const { originalname, mimetype, size, buffer } = file;
|
const { originalname, mimetype, size, buffer } = file;
|
||||||
const filename = `/${dateFormat(new Date(), 'yyyy-MM-dd')}/${uniqueid()}/${originalname}`;
|
const filename = `/${dateFormat(new Date(), 'yyyy-MM-dd')}/${uniqueid()}/${originalname}`;
|
||||||
const url = await this.ossClient.putFile(filename, buffer);
|
const url = await this.ossClient.putFile(filename, buffer);
|
||||||
|
|
Loading…
Reference in New Issue