feat: improve tiptap

pull/8/head
fantasticit 2022-03-21 16:46:27 +08:00
parent ea34e23422
commit f68303720f
46 changed files with 955 additions and 764 deletions

View File

@ -7,6 +7,6 @@
"trailingComma": "es5", "trailingComma": "es5",
"tabWidth": 2, "tabWidth": 2,
"semi": true, "semi": true,
"printWidth": 100, "printWidth": 120,
"endOfLine": "lf" "endOfLine": "lf"
} }

View File

@ -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);

View File

@ -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,

View File

@ -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>

View File

@ -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;
}

View File

@ -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>
); );
}; };

View File

@ -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>
);
};

View File

@ -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(() => {

View File

@ -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();
}, },

View File

@ -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;
}; };
} }
} }

View File

@ -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

View File

@ -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

View File

@ -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;
} }

View File

@ -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;

View File

@ -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;

View File

@ -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() {

View File

@ -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

View File

@ -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);
},
});

View File

@ -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);
} }

View File

@ -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) => {

View File

@ -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;
});
},
},
}),
];
},
});

View File

@ -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() {

View File

@ -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

View File

@ -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,
}); });

View File

@ -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);
} }
} }
} }

View File

@ -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"

View File

@ -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,

View File

@ -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>
); );
}; };

View File

@ -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(),
},
];

View File

@ -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,
}} }}

View File

@ -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>

View File

@ -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"

View File

@ -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);
}

View File

@ -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';
};

View File

@ -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);
};

View File

@ -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;
};

View File

@ -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';

View File

@ -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';
} }

View File

@ -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;
};

View File

@ -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();
} }
} }

View File

@ -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))
);
}
}

View File

@ -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;
} }
} }

View File

@ -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;
}
} }

View File

@ -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);
} }
} }

View File

@ -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'

View File

@ -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);