mirror of https://github.com/fantasticit/think.git
feat: improve tiptap
parent
ea34e23422
commit
f68303720f
|
@ -7,6 +7,6 @@
|
|||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"printWidth": 120,
|
||||
"endOfLine": "lf"
|
||||
}
|
|
@ -6,6 +6,7 @@ import { ILoginUser, IAuthority } from '@think/domains';
|
|||
import { useToggle } from 'hooks/useToggle';
|
||||
import {
|
||||
DEFAULT_EXTENSION,
|
||||
Document,
|
||||
DocumentWithTitle,
|
||||
getCollaborationExtension,
|
||||
getCollaborationCursorExtension,
|
||||
|
@ -44,6 +45,11 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
|||
},
|
||||
});
|
||||
}, [documentId, user.token]);
|
||||
|
||||
const noTitleEditor = useEditor({
|
||||
extensions: [...DEFAULT_EXTENSION, Document],
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
editable: authority && authority.editable,
|
||||
extensions: [
|
||||
|
@ -52,6 +58,10 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
|||
getCollaborationExtension(provider),
|
||||
getCollaborationCursorExtension(provider, user),
|
||||
],
|
||||
editorProps: {
|
||||
// @ts-ignore
|
||||
noTitleEditor,
|
||||
},
|
||||
});
|
||||
const [loading, toggleLoading] = useToggle(true);
|
||||
|
||||
|
|
|
@ -29,11 +29,11 @@ import { Italic } from './extensions/italic';
|
|||
import { Katex } from './extensions/katex';
|
||||
import { Link } from './extensions/link';
|
||||
import { ListItem } from './extensions/listItem';
|
||||
import { Loading } from './extensions/loading';
|
||||
import { Mind } from './extensions/mind';
|
||||
import { OrderedList } from './extensions/orderedList';
|
||||
import { Paragraph } from './extensions/paragraph';
|
||||
import { PasteFile } from './extensions/pasteFile';
|
||||
import { PasteMarkdown } from './extensions/pasteMarkdown';
|
||||
import { Paste } from './extensions/paste';
|
||||
import { Placeholder } from './extensions/placeholder';
|
||||
import { SearchNReplace } from './extensions/search';
|
||||
import { Status } from './extensions/status';
|
||||
|
@ -83,11 +83,11 @@ export const BaseKit = [
|
|||
Katex,
|
||||
Link,
|
||||
ListItem,
|
||||
Loading,
|
||||
Mind,
|
||||
OrderedList,
|
||||
Paragraph,
|
||||
PasteFile,
|
||||
PasteMarkdown,
|
||||
Paste,
|
||||
Placeholder,
|
||||
SearchNReplace,
|
||||
Status,
|
||||
|
|
|
@ -1,27 +1,84 @@
|
|||
import { useEffect, useRef } from '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 { Tooltip } from 'components/tooltip';
|
||||
import { useToggle } from 'hooks/useToggle';
|
||||
import { download } from '../../services/download';
|
||||
import { uploadFile } from 'services/file';
|
||||
import { normalizeFileSize, extractFileExtension, extractFilename } from '../../services/file';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
export const AttachmentWrapper = ({ node }) => {
|
||||
const { name, url } = node.attrs;
|
||||
const { Text } = Typography;
|
||||
|
||||
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 (
|
||||
<NodeViewWrapper as="div">
|
||||
<div className={styles.wrap}>
|
||||
<span>{name}</span>
|
||||
<span>
|
||||
<Tooltip content="下载">
|
||||
<Button
|
||||
theme={'borderless'}
|
||||
type="tertiary"
|
||||
icon={<IconDownload />}
|
||||
onClick={() => download(url, name)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
{!url ? (
|
||||
error ? (
|
||||
<Text>{error}</Text>
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Text onClick={selectFile} style={{ cursor: 'pointer' }}>
|
||||
{loading ? '正在上传中' : '请选择文件'}
|
||||
</Text>
|
||||
<input ref={$upload} type="file" hidden onChange={handleFile} />
|
||||
</Spin>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<NodeViewContent></NodeViewContent>
|
||||
</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 { 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 }) => {
|
||||
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) => {
|
||||
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 (
|
||||
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
|
||||
{isEditable ? (
|
||||
<Resizeable width={width} height={height} onChange={onResize}>
|
||||
{content}
|
||||
</Resizeable>
|
||||
) : (
|
||||
<div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{content}</div>
|
||||
)}
|
||||
{content}
|
||||
</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) => {
|
||||
const $container = useRef<HTMLDivElement>();
|
||||
const $image = useRef<HTMLInputElement>();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
|
@ -34,6 +35,10 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
|||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
const handleSelectImage = function () {
|
||||
console.log('image', this.files);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -2,6 +2,14 @@ import { Node, mergeAttributes } from '@tiptap/core';
|
|||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { AttachmentWrapper } from '../components/attachment';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
attachment: {
|
||||
setAttachment: (attrs?: unknown) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Attachment = Node.create({
|
||||
name: 'attachment',
|
||||
group: 'block',
|
||||
|
@ -26,19 +34,34 @@ export const Attachment = Node.create({
|
|||
|
||||
addAttributes() {
|
||||
return {
|
||||
name: {
|
||||
fileName: {
|
||||
default: null,
|
||||
},
|
||||
fileSize: {
|
||||
default: null,
|
||||
},
|
||||
fileType: {
|
||||
default: null,
|
||||
},
|
||||
fileExt: {
|
||||
default: null,
|
||||
},
|
||||
url: {
|
||||
default: null,
|
||||
},
|
||||
autoTrigger: {
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
// @ts-ignore
|
||||
addCommands() {
|
||||
return {
|
||||
setAttachment:
|
||||
(attrs) =>
|
||||
(attrs = {}) =>
|
||||
({ chain }) => {
|
||||
return chain().insertContent({ type: this.name, attrs }).run();
|
||||
},
|
||||
|
|
|
@ -4,9 +4,9 @@ import { BannerWrapper } from '../components/banner';
|
|||
import { typesAvailable } from '../services/markdown/markdownBanner';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
interface Commands<ReturnType> {
|
||||
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 { DocumentChildrenWrapper } from '../components/documentChildren';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
interface Commands<ReturnType> {
|
||||
documentChildren: {
|
||||
setDocumentChildren: () => Command;
|
||||
setDocumentChildren: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -35,10 +35,7 @@ export const DocumentChildren = Node.create({
|
|||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
||||
];
|
||||
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||
},
|
||||
|
||||
// @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 { DocumentReferenceWrapper } from '../components/documentReference';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
interface Commands<ReturnType> {
|
||||
documentReference: {
|
||||
setDocumentReference: () => Command;
|
||||
setDocumentReference: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -38,10 +38,7 @@ export const DocumentReference = Node.create({
|
|||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
||||
];
|
||||
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
|
|
|
@ -7,6 +7,14 @@ import tippy from 'tippy.js';
|
|||
import { EmojiList } from '../components/emojiList';
|
||||
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 { emojisToName };
|
||||
export const Emoji = Node.create({
|
||||
|
@ -30,10 +38,9 @@ export const Emoji = Node.create({
|
|||
};
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
addCommands() {
|
||||
return {
|
||||
emoji:
|
||||
setEmoji:
|
||||
(emojiObject) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent(emojiObject.emoji + ' ');
|
||||
|
@ -56,9 +63,7 @@ export const Emoji = Node.create({
|
|||
decorations: (state) => {
|
||||
if (!editor.isEditable) return;
|
||||
|
||||
const parent = findParentNode((node) => node.type.name === 'paragraph')(
|
||||
state.selection
|
||||
);
|
||||
const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -4,248 +4,11 @@ import { Plugin, PluginKey } from 'prosemirror-state';
|
|||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||
import Suggestion from '@tiptap/suggestion';
|
||||
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 { getImageOriginSize } from '../services/image';
|
||||
import { EVOKE_MENU_ITEMS } from '../menus/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({
|
||||
name: 'evokeMenu',
|
||||
|
||||
|
@ -261,7 +24,7 @@ export const EvokeMenu = Node.create({
|
|||
const tr = state.tr.deleteRange($from.start(), $from.pos);
|
||||
dispatch(tr);
|
||||
props?.command(editor);
|
||||
editor.view.focus();
|
||||
editor?.view?.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -282,9 +45,7 @@ export const EvokeMenu = Node.create({
|
|||
decorations: (state) => {
|
||||
if (!editor.isEditable) return;
|
||||
|
||||
const parent = findParentNode((node) => node.type.name === 'paragraph')(
|
||||
state.selection
|
||||
);
|
||||
const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
@ -324,7 +85,7 @@ export const EvokeMenu = Node.create({
|
|||
}).configure({
|
||||
suggestion: {
|
||||
items: ({ query }) => {
|
||||
return COMMANDS.filter((command) => command.key.startsWith(query));
|
||||
return EVOKE_MENU_ITEMS.filter((command) => command.key.startsWith(query));
|
||||
},
|
||||
render: () => {
|
||||
let component;
|
||||
|
|
|
@ -2,6 +2,14 @@ import { Node, mergeAttributes } from '@tiptap/core';
|
|||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { IframeWrapper } from '../components/iframe';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
iframe: {
|
||||
setIframe: (attrs) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Iframe = Node.create({
|
||||
name: 'external-iframe',
|
||||
content: '',
|
||||
|
@ -47,14 +55,15 @@ export const Iframe = Node.create({
|
|||
// @ts-ignore
|
||||
addCommands() {
|
||||
return {
|
||||
insertIframe:
|
||||
setIframe:
|
||||
(options) =>
|
||||
({ tr, commands, chain, editor }) => {
|
||||
// @ts-ignore
|
||||
if (tr.selection?.node?.type?.name == this.name) {
|
||||
return commands.updateAttributes(this.name, options);
|
||||
}
|
||||
|
||||
const { url } = options || {};
|
||||
const { url } = options || { url: '' };
|
||||
const { selection } = editor.state;
|
||||
const pos = selection.$head;
|
||||
|
||||
|
|
|
@ -2,8 +2,15 @@ import { Image as BuiltInImage } from '@tiptap/extension-image';
|
|||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { ImageWrapper } from '../components/image';
|
||||
|
||||
const resolveImageEl = (element) =>
|
||||
element.nodeName === 'IMG' ? element : element.querySelector('img');
|
||||
const resolveImageEl = (element) => (element.nodeName === 'IMG' ? element : element.querySelector('img'));
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
iamge: {
|
||||
setEmptyImage: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Image = BuiltInImage.extend({
|
||||
addOptions() {
|
||||
|
@ -19,7 +26,6 @@ export const Image = BuiltInImage.extend({
|
|||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const img = resolveImageEl(element);
|
||||
|
||||
return img.dataset.src || img.getAttribute('src');
|
||||
},
|
||||
},
|
||||
|
@ -40,6 +46,22 @@ export const Image = BuiltInImage.extend({
|
|||
height: {
|
||||
default: 'auto',
|
||||
},
|
||||
autoTrigger: {
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
setEmptyImage:
|
||||
(attrs = {}) =>
|
||||
({ chain }) => {
|
||||
return chain().insertContent({ type: this.name, attrs }).run();
|
||||
},
|
||||
};
|
||||
},
|
||||
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 { KatexWrapper } from '../components/katex';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
interface Commands<ReturnType> {
|
||||
katex: {
|
||||
setKatex: () => Command;
|
||||
setKatex: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -33,10 +33,7 @@ export const Katex = Node.create({
|
|||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
||||
];
|
||||
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||
},
|
||||
|
||||
// @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: [] },
|
||||
};
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
mind: {
|
||||
setMind: (attrs?: unknown) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Mind = Node.create({
|
||||
name: 'jsmind',
|
||||
content: '',
|
||||
|
@ -57,9 +65,10 @@ export const Mind = Node.create({
|
|||
// @ts-ignore
|
||||
addCommands() {
|
||||
return {
|
||||
insertMind:
|
||||
setMind:
|
||||
(options) =>
|
||||
({ tr, commands, chain, editor }) => {
|
||||
// @ts-ignore
|
||||
if (tr.selection?.node?.type?.name == this.name) {
|
||||
return commands.updateAttributes(this.name, options);
|
||||
}
|
||||
|
|
|
@ -1,90 +1,37 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
|
||||
// @ts-ignore
|
||||
import { lowlight } from 'lowlight';
|
||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||
import { markdownSerializer } from '../services/markdown';
|
||||
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 =
|
||||
(type) =>
|
||||
(state: EditorState): boolean => {
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { from, $from, to, empty } = state.selection;
|
||||
|
||||
return empty
|
||||
? type.isInSet(state.storedMarks || $from.marks())
|
||||
: state.doc.rangeHasMark(from, to, type);
|
||||
};
|
||||
|
||||
export default function isInCode(state: EditorState): boolean {
|
||||
if (state.schema.nodes.codeBlock) {
|
||||
const $head = state.selection.$head;
|
||||
for (let d = $head.depth; d > 0; d--) {
|
||||
if ($head.node(d).type === state.schema.nodes.codeBlock) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isMarkActive(state.schema.marks.code)(state);
|
||||
}
|
||||
|
||||
const LANGUAGES = lowlight.listLanguages().reduce((a, language) => {
|
||||
a[language] = language;
|
||||
return a;
|
||||
}, {});
|
||||
|
||||
export const acceptedMimes = {
|
||||
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
|
||||
};
|
||||
|
||||
function isMarkdown(text: string): boolean {
|
||||
// code-ish
|
||||
const fences = text.match(/^```/gm);
|
||||
if (fences && fences.length > 1) return true;
|
||||
|
||||
// link-ish
|
||||
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
|
||||
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
|
||||
|
||||
// heading-ish
|
||||
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
|
||||
|
||||
// list-ish
|
||||
const listItems = text.match(/^[\d-*].?\s\S+/gm);
|
||||
if (listItems && listItems.length > 1) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizePastedMarkdown(text: string): string {
|
||||
const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim;
|
||||
|
||||
while (text.match(CHECKBOX_REGEX)) {
|
||||
text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export const PasteMarkdown = Extension.create({
|
||||
name: 'pasteMarkdown',
|
||||
export const Paste = Extension.create({
|
||||
name: 'paste',
|
||||
priority: EXTENSION_PRIORITY_HIGHEST,
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('pasteMarkdown'),
|
||||
key: new PluginKey('paste'),
|
||||
props: {
|
||||
// @ts-ignore
|
||||
handlePaste: async (view, event: ClipboardEvent) => {
|
||||
handlePaste: (view, event: ClipboardEvent) => {
|
||||
if (view.props.editable && !view.props.editable(view.state)) {
|
||||
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 html = event.clipboardData.getData('text/html');
|
||||
const vscode = event.clipboardData.getData('vscode-editor-data');
|
||||
|
@ -104,9 +51,7 @@ export const PasteMarkdown = Extension.create({
|
|||
view.dispatch(
|
||||
view.state.tr.replaceSelectionWith(
|
||||
view.state.schema.nodes.codeBlock.create({
|
||||
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
|
||||
? vscodeMeta.mode
|
||||
: null,
|
||||
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode) ? vscodeMeta.mode : null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
@ -117,16 +62,46 @@ export const PasteMarkdown = Extension.create({
|
|||
// 处理 markdown
|
||||
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
||||
event.preventDefault();
|
||||
const paste = markdownSerializer.deserialize({
|
||||
schema: view.props.state.schema,
|
||||
// FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在
|
||||
// const hasTitle = isTitleNode(view.props.state.doc.content.firstChild);
|
||||
let schema = view.props.state.schema;
|
||||
const doc = markdownSerializer.deserialize({
|
||||
schema,
|
||||
content: normalizePastedMarkdown(text),
|
||||
});
|
||||
// @ts-ignore
|
||||
const transaction = view.state.tr.replaceSelectionWith(paste);
|
||||
const transaction = view.state.tr.insert(view.state.selection.head, doc);
|
||||
view.dispatch(transaction);
|
||||
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;
|
||||
},
|
||||
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 { EditorState, Plugin, PluginKey } from 'prosemirror-state';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
|
@ -193,7 +194,7 @@ const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, g
|
|||
setTimeout(() => {
|
||||
const el = window.document.querySelector(`.${searchResultCurrentClass}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' });
|
||||
}
|
||||
}, 0);
|
||||
|
||||
|
@ -207,15 +208,17 @@ const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, g
|
|||
export const SearchNReplace = Extension.create<SearchOptions>({
|
||||
name: 'search',
|
||||
|
||||
defaultOptions: {
|
||||
searchTerm: '',
|
||||
replaceTerm: '',
|
||||
results: [],
|
||||
currentIndex: 0,
|
||||
searchResultClass: 'search-result',
|
||||
searchResultCurrentClass: 'search-result-current',
|
||||
caseSensitive: false,
|
||||
disableRegex: false,
|
||||
addOptions() {
|
||||
return {
|
||||
searchTerm: '',
|
||||
replaceTerm: '',
|
||||
results: [],
|
||||
currentIndex: 0,
|
||||
searchResultClass: 'search-result',
|
||||
searchResultCurrentClass: 'search-result-current',
|
||||
caseSensitive: false,
|
||||
disableRegex: false,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Node, Command, mergeAttributes } from '@tiptap/core';
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { StatusWrapper } from '../components/status';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands {
|
||||
interface Commands<ReturnType> {
|
||||
status: {
|
||||
setStatus: () => Command;
|
||||
setStatus: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -33,10 +33,7 @@ export const Status = Node.create({
|
|||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
|
||||
];
|
||||
return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
|
|
|
@ -1,14 +1,5 @@
|
|||
import { Table as BuiltInTable } from '@tiptap/extension-table';
|
||||
import { TableView } from '../views/tableView';
|
||||
|
||||
export const Table = BuiltInTable.extend({
|
||||
// @ts-ignore
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
View: TableView,
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
resizable: true,
|
||||
export const Table = BuiltInTable.configure({
|
||||
resizable: false,
|
||||
});
|
||||
|
|
|
@ -13,7 +13,6 @@ import {
|
|||
selectRow,
|
||||
selectTable,
|
||||
} from '../services/table';
|
||||
import { elementInViewport } from '../services/dom';
|
||||
import { FloatMenuView } from '../views/floatMenuView';
|
||||
|
||||
export const TableCell = BuiltInTableCell.extend({
|
||||
|
@ -27,27 +26,27 @@ export const TableCell = BuiltInTableCell.extend({
|
|||
view: () =>
|
||||
new FloatMenuView({
|
||||
editor: this.editor,
|
||||
tippyOptions: {
|
||||
zIndex: 10000,
|
||||
offset: [-28, 0],
|
||||
},
|
||||
shouldShow: ({ editor }, floatMenuView) => {
|
||||
if (!editor.isEditable) {
|
||||
return false;
|
||||
}
|
||||
if (isTableSelected(editor.state.selection)) {
|
||||
return false;
|
||||
}
|
||||
const cells = getCellsInColumn(0)(editor.state.selection);
|
||||
if (selectedRowIndex > -1) {
|
||||
// 获取当前行的第一个单元格的位置
|
||||
const rowCells = getCellsInRow(selectedRowIndex)(editor.state.selection);
|
||||
if (rowCells && rowCells[0]) {
|
||||
const node = editor.view.nodeDOM(rowCells[0].pos) as HTMLElement;
|
||||
if (node) {
|
||||
const el = node.querySelector('a.grip-row') as HTMLElement;
|
||||
if (el) {
|
||||
console.log({ 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: () =>
|
||||
new FloatMenuView({
|
||||
editor: this.editor,
|
||||
tippyOptions: {
|
||||
zIndex: 10000,
|
||||
},
|
||||
shouldShow: ({ editor }) => {
|
||||
if (!editor.isEditable) {
|
||||
return false;
|
||||
|
@ -44,7 +47,7 @@ export const TableHeader = BuiltInTableHeader.extend({
|
|||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="向后插入一列">
|
||||
<Tooltip content="删除当前列">
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
|
@ -55,7 +58,7 @@ export const TableHeader = BuiltInTableHeader.extend({
|
|||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="删除当前列" hideOnClick>
|
||||
<Tooltip content="向后插入一列" hideOnClick>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core';
|
|||
|
||||
export const Title = Node.create({
|
||||
name: 'title',
|
||||
content: 'inline*',
|
||||
content: 'text*',
|
||||
selectable: true,
|
||||
defining: true,
|
||||
inline: false,
|
||||
|
|
|
@ -22,7 +22,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Space spacing={2}>
|
||||
<MediaInsertMenu editor={editor} />
|
||||
|
||||
|
@ -77,7 +77,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
<LinkBubbleMenu editor={editor} />
|
||||
<BannerBubbleMenu 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 { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconAlignLeft,
|
||||
IconAlignCenter,
|
||||
IconAlignRight,
|
||||
IconUpload,
|
||||
IconDelete,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconUpload, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { Upload } from 'components/upload';
|
||||
import { BubbleMenu } from './components/bubbleMenu';
|
||||
|
@ -27,12 +21,14 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
setHeight(parseInt(currentHeight));
|
||||
}, [currentWidth, currentHeight]);
|
||||
|
||||
console.log(attrs);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
editor={editor}
|
||||
pluginKey="image-bubble-menu"
|
||||
shouldShow={() => editor.isActive(Image.name)}
|
||||
shouldShow={() => editor.isActive(Image.name) && !!attrs.src}
|
||||
tippyOptions={{
|
||||
maxWidth: 456,
|
||||
}}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { Button, Dropdown, Popover } from '@douyinfe/semi-ui';
|
||||
import { IconPlus } from '@douyinfe/semi-icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { Upload } from 'components/upload';
|
||||
import { Upload } from './components/upload';
|
||||
import {
|
||||
IconDocument,
|
||||
IconMind,
|
||||
|
@ -17,9 +18,9 @@ import {
|
|||
} from 'components/icons';
|
||||
import { GridSelect } from 'components/grid-select';
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
@ -45,11 +46,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
<div style={{ padding: 0 }}>
|
||||
<GridSelect
|
||||
onSelect={({ rows, cols }) => {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows, cols, withHeaderRow: true })
|
||||
.run();
|
||||
return editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -63,33 +60,20 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
<Dropdown.Item onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||
<IconCodeBlock /> 代码块
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
|
||||
<Dropdown.Item onClick={() => editor.chain().focus().setEmptyImage().run()}>
|
||||
<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 onClick={() => editor.chain().focus().setAttachment().run()}>
|
||||
<IconAttachment />
|
||||
<Upload
|
||||
onOK={(url, name) => {
|
||||
editor.chain().focus().setAttachment({ url, name }).run();
|
||||
}}
|
||||
>
|
||||
{() => '附件'}
|
||||
</Upload>
|
||||
附件
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => editor.chain().focus().insertIframe({ url: '' }).run()}>
|
||||
|
||||
<Dropdown.Item onClick={() => editor.chain().focus().setIframe({ url: '' }).run()}>
|
||||
<IconLink /> 外链
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => editor.chain().focus().insertMind().run()}>
|
||||
<Dropdown.Item onClick={() => editor.chain().focus().setMind().run()}>
|
||||
<IconMind /> 思维导图
|
||||
</Dropdown.Item>
|
||||
|
||||
|
@ -119,12 +103,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
>
|
||||
<div>
|
||||
<Tooltip content="插入">
|
||||
<Button
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconPlus />}
|
||||
disabled={isTitleActive(editor)}
|
||||
/>
|
||||
<Button type="tertiary" theme="borderless" icon={<IconPlus />} disabled={isTitleActive(editor)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
IconDeleteTable,
|
||||
} from 'components/icons';
|
||||
import { Tooltip } from 'components/tooltip';
|
||||
import { Divider } from '../components/divider';
|
||||
import { BubbleMenu } from './components/bubbleMenu';
|
||||
import { Table } from '../extensions/table';
|
||||
|
||||
|
@ -24,9 +25,7 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
tippyOptions={{
|
||||
maxWidth: 456,
|
||||
}}
|
||||
matchRenderContainer={(node: HTMLElement) =>
|
||||
node.classList && node.classList.contains('tableWrapper')
|
||||
}
|
||||
matchRenderContainer={(node: HTMLElement) => node.classList && node.tagName === 'TABLE'}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="向前插入一列">
|
||||
|
@ -58,6 +57,8 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip content="向前插入一行">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().addRowBefore().run()}
|
||||
|
@ -88,6 +89,8 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip content="合并单元格">
|
||||
<Button
|
||||
size="small"
|
||||
|
@ -108,6 +111,8 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip content="删除表格" hideOnClick>
|
||||
<Button
|
||||
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) => {
|
||||
return (
|
||||
editor.isActive('bulletList') || editor.isActive('orderedList') || editor.isActive('taskList')
|
||||
);
|
||||
return editor.isActive('bulletList') || editor.isActive('orderedList') || editor.isActive('taskList');
|
||||
};
|
||||
|
||||
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 { sanitize } from 'dompurify';
|
||||
import {
|
||||
MarkdownSerializer as ProseMirrorMarkdownSerializer,
|
||||
defaultMarkdownSerializer,
|
||||
} from 'prosemirror-markdown';
|
||||
import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { markdown } from '.';
|
||||
import { Attachment } from '../../extensions/attachment';
|
||||
import { Banner } from '../../extensions/banner';
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import { Node } from 'prosemirror-model';
|
||||
|
||||
export function isTitleNode(node: Node): boolean {
|
||||
return node.type.name === 'title';
|
||||
}
|
||||
|
||||
export function isBulletListNode(node: Node): boolean {
|
||||
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 _update: FloatMenuViewOptions['update'];
|
||||
private shouldShow: FloatMenuViewOptions['shouldShow'];
|
||||
private tippyOptions: FloatMenuViewOptions['tippyOptions'];
|
||||
private getReferenceClientRect: NonNullable<FloatMenuViewOptions['getReferenceClientRect']> = ({
|
||||
editor,
|
||||
range,
|
||||
|
@ -47,12 +48,16 @@ export class FloatMenuView {
|
|||
return node.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
if (this.parentNode) {
|
||||
return this.parentNode.getBoundingClientRect();
|
||||
}
|
||||
return posToDOMRect(view, range.from, range.to);
|
||||
};
|
||||
|
||||
constructor(props: FloatMenuViewOptions) {
|
||||
this.editor = props.editor;
|
||||
this.shouldShow = props.shouldShow;
|
||||
this.tippyOptions = props.tippyOptions;
|
||||
if (props.getReferenceClientRect) {
|
||||
this.getReferenceClientRect = props.getReferenceClientRect;
|
||||
}
|
||||
|
@ -63,15 +68,25 @@ export class FloatMenuView {
|
|||
props.init(this.dom, this.editor);
|
||||
|
||||
// popup
|
||||
this.popup = tippy(document.body, {
|
||||
appendTo: () => document.body,
|
||||
this.createPopup();
|
||||
}
|
||||
|
||||
createPopup() {
|
||||
const { element: editorElement } = this.editor.options;
|
||||
const editorIsAttached = !!editorElement.parentElement;
|
||||
|
||||
if (this.popup || !editorIsAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.popup = tippy(editorElement, {
|
||||
getReferenceClientRect: null,
|
||||
content: this.dom,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'top',
|
||||
hideOnClick: 'toggle',
|
||||
...(props.tippyOptions ?? {}),
|
||||
...(this.tippyOptions ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -84,6 +99,8 @@ export class FloatMenuView {
|
|||
return;
|
||||
}
|
||||
|
||||
this.createPopup();
|
||||
|
||||
const { ranges } = selection;
|
||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||
|
@ -118,10 +135,6 @@ export class FloatMenuView {
|
|||
|
||||
this.popup.setProps({
|
||||
getReferenceClientRect: () => {
|
||||
if (this.parentNode) {
|
||||
return this.parentNode.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return this.getReferenceClientRect({
|
||||
editor: this.editor,
|
||||
oldState,
|
||||
|
@ -137,14 +150,14 @@ export class FloatMenuView {
|
|||
}
|
||||
|
||||
show() {
|
||||
this.popup.show();
|
||||
this.popup?.show();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popup.hide();
|
||||
this.popup?.hide();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
&.table-bubble-menu {
|
||||
transform: translateY(-1em);
|
||||
transform: translateY(-2em);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,13 +18,11 @@
|
|||
border-radius: 3px;
|
||||
box-shadow: var(--box-shadow);
|
||||
background-color: var(--semi-color-nav-bg);
|
||||
transform: translateY(-1em);
|
||||
|
||||
&.row {
|
||||
column-gap: 8px;
|
||||
flex-direction: column;
|
||||
transform: translate(-100%, 75%);
|
||||
margin-left: -1.2em;
|
||||
transform: translate(0, 70%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -199,17 +199,8 @@ a {
|
|||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.react-tooltip {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
background-color: rgba(var(--semi-grey-7), 1) !important;
|
||||
color: var(--semi-color-bg-0) !important;
|
||||
border-radius: var(--semi-border-radius-medium) !important;
|
||||
padding: 6px 8px !important;
|
||||
z-index: 10000 !important;
|
||||
white-space: nowrap;
|
||||
|
||||
&::after {
|
||||
border-top-color: rgba(var(--semi-grey-7), 1) !important;
|
||||
}
|
||||
.semi-spin-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
@ -299,17 +299,19 @@
|
|||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 1em 0;
|
||||
|
||||
td,
|
||||
th {
|
||||
position: relative;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--semi-color-fill-2);
|
||||
box-sizing: border-box;
|
||||
min-width: 1em;
|
||||
padding: 3px 5px;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
overflow: visible;
|
||||
|
||||
|
@ -320,7 +322,6 @@
|
|||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.selectedCell {
|
||||
|
@ -330,68 +331,55 @@
|
|||
}
|
||||
|
||||
.grip-column {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.7em;
|
||||
left: 0;
|
||||
top: -1em;
|
||||
margin-bottom: 3px;
|
||||
cursor: pointer;
|
||||
background: #ced4da;
|
||||
}
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.7em;
|
||||
left: 0;
|
||||
top: -1em;
|
||||
margin-bottom: 3px;
|
||||
cursor: pointer;
|
||||
background: #ced4da;
|
||||
|
||||
&:hover::after {
|
||||
background: var(--semi-color-info);
|
||||
}
|
||||
|
||||
&.selected::after {
|
||||
&:hover,
|
||||
&.selected {
|
||||
background: var(--semi-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.grip-row {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0.7em;
|
||||
top: 0;
|
||||
left: -1em;
|
||||
margin-right: 3px;
|
||||
cursor: pointer;
|
||||
background: #ced4da;
|
||||
}
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0.7em;
|
||||
top: 0;
|
||||
left: -1em;
|
||||
margin-right: 3px;
|
||||
cursor: pointer;
|
||||
background: #ced4da;
|
||||
|
||||
&:hover::after {
|
||||
background: var(--semi-color-info);
|
||||
}
|
||||
|
||||
&.selected::after {
|
||||
&:hover,
|
||||
&.selected {
|
||||
background: var(--semi-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.grip-table {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
top: -1em;
|
||||
left: -1em;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background: #ced4da;
|
||||
}
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
top: -1em;
|
||||
left: -1em;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background: #ced4da;
|
||||
|
||||
&:hover::after {
|
||||
background: var(--semi-color-info);
|
||||
}
|
||||
|
||||
&.selected::after {
|
||||
&:hover,
|
||||
&.selected {
|
||||
background: var(--semi-color-info);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,36 +1,36 @@
|
|||
# 开发环境配置
|
||||
server:
|
||||
prefix: "/api"
|
||||
prefix: '/api'
|
||||
port: 5001
|
||||
collaborationPort: 5003
|
||||
|
||||
client:
|
||||
assetPrefix: "/"
|
||||
apiUrl: "http://localhost:5001/api"
|
||||
collaborationUrl: "ws://localhost:5003"
|
||||
assetPrefix: '/'
|
||||
apiUrl: 'http://localhost:5001/api'
|
||||
collaborationUrl: 'ws://localhost:5003'
|
||||
|
||||
# 数据库配置
|
||||
db:
|
||||
mysql:
|
||||
host: "127.0.0.1"
|
||||
username: "root"
|
||||
password: "root"
|
||||
database: "think"
|
||||
host: '127.0.0.1'
|
||||
username: 'root'
|
||||
password: 'root'
|
||||
database: 'think'
|
||||
port: 3306
|
||||
charset: "utf8mb4"
|
||||
timezone: "+08:00"
|
||||
charset: 'utf8mb4'
|
||||
timezone: '+08:00'
|
||||
synchronize: true
|
||||
|
||||
# oss 文件存储服务
|
||||
oss:
|
||||
aliyun:
|
||||
accessKeyId: "LTAI4Fc65yMwx2LR23PZwUed"
|
||||
accessKeySecret: "I0EBFxRTWLyVk674raeYgC8h1tvSqG"
|
||||
bucket: "wipi"
|
||||
accessKeyId: 'LTAI5tNd19aX1TZJwGjKJWVE'
|
||||
accessKeySecret: 'nlZJQprIO45QDeeANVlBZ6F7SIjBZs'
|
||||
bucket: 'wipi'
|
||||
https: true
|
||||
region: "oss-cn-shanghai"
|
||||
region: 'oss-cn-shanghai'
|
||||
|
||||
# jwt 配置
|
||||
jwt:
|
||||
secretkey: "zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022"
|
||||
expiresIn: "6h"
|
||||
secretkey: 'zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022'
|
||||
expiresIn: '6h'
|
||||
|
|
|
@ -17,6 +17,7 @@ export class FileService {
|
|||
* @param file
|
||||
*/
|
||||
async uploadFile(file) {
|
||||
console.log('upload', file);
|
||||
const { originalname, mimetype, size, buffer } = file;
|
||||
const filename = `/${dateFormat(new Date(), 'yyyy-MM-dd')}/${uniqueid()}/${originalname}`;
|
||||
const url = await this.ossClient.putFile(filename, buffer);
|
||||
|
|
Loading…
Reference in New Issue