From f68303720f8a534b95550b9cbb9d20fa6ffcddee Mon Sep 17 00:00:00 2001 From: fantasticit Date: Mon, 21 Mar 2022 16:46:27 +0800 Subject: [PATCH] feat: improve tiptap --- .prettierrc | 2 +- .../src/components/document/editor/editor.tsx | 10 + .../client/src/components/tiptap/basekit.tsx | 8 +- .../tiptap/components/attachment/index.tsx | 85 +++++- .../tiptap/components/image/index.module.scss | 10 + .../tiptap/components/image/index.tsx | 82 +++++- .../components/tiptap/components/loading.tsx | 26 ++ .../tiptap/components/menuList/index.tsx | 5 + .../tiptap/extensions/attachment.ts | 27 +- .../components/tiptap/extensions/banner.ts | 4 +- .../tiptap/extensions/documentChildren.ts | 11 +- .../tiptap/extensions/documentReference.ts | 11 +- .../src/components/tiptap/extensions/emoji.ts | 15 +- .../tiptap/extensions/evokeMenu.tsx | 247 +----------------- .../components/tiptap/extensions/iframe.ts | 13 +- .../src/components/tiptap/extensions/image.ts | 28 +- .../src/components/tiptap/extensions/katex.ts | 11 +- .../components/tiptap/extensions/loading.ts | 22 ++ .../src/components/tiptap/extensions/mind.ts | 11 +- .../extensions/{pasteMarkdown.ts => paste.ts} | 133 ++++------ .../components/tiptap/extensions/pasteFile.ts | 83 ------ .../components/tiptap/extensions/search.ts | 23 +- .../components/tiptap/extensions/status.ts | 11 +- .../src/components/tiptap/extensions/table.ts | 13 +- .../tiptap/extensions/tableCell.tsx | 17 +- .../tiptap/extensions/tableHeader.tsx | 7 +- .../components/tiptap/extensions/title.tsx | 2 +- .../client/src/components/tiptap/menubar.tsx | 4 +- .../src/components/tiptap/menus/evokeMenu.tsx | 221 ++++++++++++++++ .../src/components/tiptap/menus/image.tsx | 12 +- .../components/tiptap/menus/mediaInsert.tsx | 49 +--- .../src/components/tiptap/menus/table.tsx | 11 +- .../src/components/tiptap/services/code.ts | 22 ++ .../src/components/tiptap/services/file.ts | 40 +++ .../components/tiptap/services/isActive.ts | 18 +- .../tiptap/services/markdown/helpers.ts | 34 +++ .../tiptap/services/markdown/serializer.ts | 5 +- .../src/components/tiptap/services/node.ts | 4 + .../src/components/tiptap/services/upload.ts | 94 +++++++ .../components/tiptap/views/floatMenuView.tsx | 33 ++- .../src/components/tiptap/views/tableView.tsx | 103 -------- packages/client/src/styles/extension.scss | 6 +- packages/client/src/styles/globals.scss | 17 +- packages/client/src/styles/prosemirror.scss | 96 +++---- packages/config/yaml/dev.yaml | 32 +-- packages/server/src/services/file.service.ts | 1 + 46 files changed, 955 insertions(+), 764 deletions(-) create mode 100644 packages/client/src/components/tiptap/components/image/index.module.scss create mode 100644 packages/client/src/components/tiptap/components/loading.tsx create mode 100644 packages/client/src/components/tiptap/extensions/loading.ts rename packages/client/src/components/tiptap/extensions/{pasteMarkdown.ts => paste.ts} (51%) delete mode 100644 packages/client/src/components/tiptap/extensions/pasteFile.ts create mode 100644 packages/client/src/components/tiptap/menus/evokeMenu.tsx create mode 100644 packages/client/src/components/tiptap/services/code.ts create mode 100644 packages/client/src/components/tiptap/services/file.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/helpers.ts create mode 100644 packages/client/src/components/tiptap/services/upload.ts delete mode 100644 packages/client/src/components/tiptap/views/tableView.tsx diff --git a/.prettierrc b/.prettierrc index a041f951..0a2f1664 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,6 +7,6 @@ "trailingComma": "es5", "tabWidth": 2, "semi": true, - "printWidth": 100, + "printWidth": 120, "endOfLine": "lf" } \ No newline at end of file diff --git a/packages/client/src/components/document/editor/editor.tsx b/packages/client/src/components/document/editor/editor.tsx index 25bed9be..4320408d 100644 --- a/packages/client/src/components/document/editor/editor.tsx +++ b/packages/client/src/components/document/editor/editor.tsx @@ -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 = ({ 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 = ({ user, documentId, authority, classNam getCollaborationExtension(provider), getCollaborationCursorExtension(provider, user), ], + editorProps: { + // @ts-ignore + noTitleEditor, + }, }); const [loading, toggleLoading] = useToggle(true); diff --git a/packages/client/src/components/tiptap/basekit.tsx b/packages/client/src/components/tiptap/basekit.tsx index e4fb2f5f..aefd3c6d 100644 --- a/packages/client/src/components/tiptap/basekit.tsx +++ b/packages/client/src/components/tiptap/basekit.tsx @@ -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, diff --git a/packages/client/src/components/tiptap/components/attachment/index.tsx b/packages/client/src/components/tiptap/components/attachment/index.tsx index 009bdc5c..bd3d1442 100644 --- a/packages/client/src/components/tiptap/components/attachment/index.tsx +++ b/packages/client/src/components/tiptap/components/attachment/index.tsx @@ -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 (
- {name} - - -
diff --git a/packages/client/src/components/tiptap/components/image/index.module.scss b/packages/client/src/components/tiptap/components/image/index.module.scss new file mode 100644 index 00000000..49be133b --- /dev/null +++ b/packages/client/src/components/tiptap/components/image/index.module.scss @@ -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; +} diff --git a/packages/client/src/components/tiptap/components/image/index.tsx b/packages/client/src/components/tiptap/components/image/index.tsx index da645271..ef52e829 100644 --- a/packages/client/src/components/tiptap/components/image/index.tsx +++ b/packages/client/src/components/tiptap/components/image/index.tsx @@ -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 && {alt}; + 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 {error}; + } + + if (!src) { + return ( +
+ + + {loading ? '正在上传中' : '请选择图片'} + + + +
+ ); + } + + const img = {alt}; + + if (isEditable) { + return ( + + {img} + + ); + } + + return
{img}
; + })(); return ( - {isEditable ? ( - - {content} - - ) : ( -
{content}
- )} + {content}
); }; diff --git a/packages/client/src/components/tiptap/components/loading.tsx b/packages/client/src/components/tiptap/components/loading.tsx new file mode 100644 index 00000000..ff5deb11 --- /dev/null +++ b/packages/client/src/components/tiptap/components/loading.tsx @@ -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 ; + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/client/src/components/tiptap/components/menuList/index.tsx b/packages/client/src/components/tiptap/components/menuList/index.tsx index 0b17fd14..b4d4a987 100644 --- a/packages/client/src/components/tiptap/components/menuList/index.tsx +++ b/packages/client/src/components/tiptap/components/menuList/index.tsx @@ -12,6 +12,7 @@ interface IProps { export const MenuList: React.FC = forwardRef((props, ref) => { const $container = useRef(); + const $image = useRef(); const [selectedIndex, setSelectedIndex] = useState(0); const selectItem = (index) => { @@ -34,6 +35,10 @@ export const MenuList: React.FC = forwardRef((props, ref) => { selectItem(selectedIndex); }; + const handleSelectImage = function () { + console.log('image', this.files); + }; + useEffect(() => setSelectedIndex(0), [props.items]); useEffect(() => { diff --git a/packages/client/src/components/tiptap/extensions/attachment.ts b/packages/client/src/components/tiptap/extensions/attachment.ts index 0a3e6c44..b9eba454 100644 --- a/packages/client/src/components/tiptap/extensions/attachment.ts +++ b/packages/client/src/components/tiptap/extensions/attachment.ts @@ -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 { + 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(); }, diff --git a/packages/client/src/components/tiptap/extensions/banner.ts b/packages/client/src/components/tiptap/extensions/banner.ts index 5ee56e71..a25a9bc4 100644 --- a/packages/client/src/components/tiptap/extensions/banner.ts +++ b/packages/client/src/components/tiptap/extensions/banner.ts @@ -4,9 +4,9 @@ import { BannerWrapper } from '../components/banner'; import { typesAvailable } from '../services/markdown/markdownBanner'; declare module '@tiptap/core' { - interface Commands { + interface Commands { banner: { - setBanner: () => Command; + setBanner: (attrs) => ReturnType; }; } } diff --git a/packages/client/src/components/tiptap/extensions/documentChildren.ts b/packages/client/src/components/tiptap/extensions/documentChildren.ts index 19eb4356..5609c2f8 100644 --- a/packages/client/src/components/tiptap/extensions/documentChildren.ts +++ b/packages/client/src/components/tiptap/extensions/documentChildren.ts @@ -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 { 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 diff --git a/packages/client/src/components/tiptap/extensions/documentReference.ts b/packages/client/src/components/tiptap/extensions/documentReference.ts index 35bd2f12..08d09a33 100644 --- a/packages/client/src/components/tiptap/extensions/documentReference.ts +++ b/packages/client/src/components/tiptap/extensions/documentReference.ts @@ -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 { 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 diff --git a/packages/client/src/components/tiptap/extensions/emoji.ts b/packages/client/src/components/tiptap/extensions/emoji.ts index 1c4cbbf6..1fff0746 100644 --- a/packages/client/src/components/tiptap/extensions/emoji.ts +++ b/packages/client/src/components/tiptap/extensions/emoji.ts @@ -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 { + 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; } diff --git a/packages/client/src/components/tiptap/extensions/evokeMenu.tsx b/packages/client/src/components/tiptap/extensions/evokeMenu.tsx index f9c665e4..6959543b 100644 --- a/packages/client/src/components/tiptap/extensions/evokeMenu.tsx +++ b/packages/client/src/components/tiptap/extensions/evokeMenu.tsx @@ -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: ( - - - 无序列表 - - ), - command: (editor) => editor.chain().focus().toggleBulletList().run(), - }, - { - key: '有序列表', - label: ( - - - 有序列表 - - ), - command: (editor) => editor.chain().focus().toggleOrderedList().run(), - }, - { - key: '任务列表', - label: ( - - - 任务列表 - - ), - command: (editor) => editor.chain().focus().toggleTaskList().run(), - }, - { - key: '链接', - label: ( - - - 链接 - - ), - command: (editor) => editor.chain().focus().toggleLink().run(), - }, - { - key: '引用', - label: ( - - - 引用 - - ), - command: (editor) => editor.chain().focus().toggleBlockquote().run(), - }, - { - key: '分割线', - label: ( - - - 分割线 - - ), - command: (editor) => editor.chain().focus().setHorizontalRule().run(), - }, - { - key: '表格', - label: ( - - - 表格 - - ), - command: (editor) => - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), - }, - { - key: '代码块', - label: ( - - - 代码块 - - ), - command: (editor) => editor.chain().focus().toggleCodeBlock().run(), - }, - { - key: '图片', - label: (editor) => ( - - - { - const { width, height } = await getImageOriginSize(url); - console.log('upload', width, height); - editor.chain().focus().setImage({ src: url, alt: fileName, width, height }).run(); - }} - > - {() => '图片'} - - - ), - command: (editor) => {}, - }, - { - key: '附件', - label: (editor) => ( - - - { - editor.chain().focus().setAttachment({ url, name }).run(); - }} - > - {() => '附件'} - - - ), - command: (editor) => {}, - }, - { - key: '外链', - label: ( - - - 外链 - - ), - command: (editor) => editor.chain().focus().insertIframe({ url: '' }).run(), - }, - { - key: '思维导图', - label: ( - - - 思维导图 - - ), - command: (editor) => editor.chain().focus().insertMind().run(), - }, - { - key: '数学公式', - label: ( - - - 数学公式 - - ), - command: (editor) => editor.chain().focus().setKatex().run(), - }, - { - key: '状态', - label: ( - - - 状态 - - ), - command: (editor) => editor.chain().focus().setStatus().run(), - }, - { - key: '信息框', - label: ( - - - 信息框 - - ), - command: (editor) => editor.chain().focus().setBanner({ type: 'info' }).run(), - }, - { - key: '文档', - label: ( - - - 文档 - - ), - command: (editor) => editor.chain().focus().setDocumentReference().run(), - }, - { - key: '子文档', - label: ( - - - 子文档 - - ), - 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; diff --git a/packages/client/src/components/tiptap/extensions/iframe.ts b/packages/client/src/components/tiptap/extensions/iframe.ts index fbaf188f..bdd337e8 100644 --- a/packages/client/src/components/tiptap/extensions/iframe.ts +++ b/packages/client/src/components/tiptap/extensions/iframe.ts @@ -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 { + 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; diff --git a/packages/client/src/components/tiptap/extensions/image.ts b/packages/client/src/components/tiptap/extensions/image.ts index 9573d5f5..80d9a614 100644 --- a/packages/client/src/components/tiptap/extensions/image.ts +++ b/packages/client/src/components/tiptap/extensions/image.ts @@ -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 { + 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() { diff --git a/packages/client/src/components/tiptap/extensions/katex.ts b/packages/client/src/components/tiptap/extensions/katex.ts index f35b6b9c..0df0480f 100644 --- a/packages/client/src/components/tiptap/extensions/katex.ts +++ b/packages/client/src/components/tiptap/extensions/katex.ts @@ -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 { 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 diff --git a/packages/client/src/components/tiptap/extensions/loading.ts b/packages/client/src/components/tiptap/extensions/loading.ts new file mode 100644 index 00000000..20d81a72 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/loading.ts @@ -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); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/mind.ts b/packages/client/src/components/tiptap/extensions/mind.ts index 53e5a881..f55d47fc 100644 --- a/packages/client/src/components/tiptap/extensions/mind.ts +++ b/packages/client/src/components/tiptap/extensions/mind.ts @@ -12,6 +12,14 @@ const DEFAULT_MIND_DATA = { data: { id: 'root', topic: '中心节点', children: [] }, }; +declare module '@tiptap/core' { + interface Commands { + 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); } diff --git a/packages/client/src/components/tiptap/extensions/pasteMarkdown.ts b/packages/client/src/components/tiptap/extensions/paste.ts similarity index 51% rename from packages/client/src/components/tiptap/extensions/pasteMarkdown.ts rename to packages/client/src/components/tiptap/extensions/paste.ts index 56357029..811e3e75 100644 --- a/packages/client/src/components/tiptap/extensions/pasteMarkdown.ts +++ b/packages/client/src/components/tiptap/extensions/paste.ts @@ -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) => { diff --git a/packages/client/src/components/tiptap/extensions/pasteFile.ts b/packages/client/src/components/tiptap/extensions/pasteFile.ts deleted file mode 100644 index 54967c87..00000000 --- a/packages/client/src/components/tiptap/extensions/pasteFile.ts +++ /dev/null @@ -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; - }); - }, - }, - }), - ]; - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/search.ts b/packages/client/src/components/tiptap/extensions/search.ts index 2622aa66..c71b18f0 100644 --- a/packages/client/src/components/tiptap/extensions/search.ts +++ b/packages/client/src/components/tiptap/extensions/search.ts @@ -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 { @@ -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({ 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() { diff --git a/packages/client/src/components/tiptap/extensions/status.ts b/packages/client/src/components/tiptap/extensions/status.ts index b03f32e9..43d87098 100644 --- a/packages/client/src/components/tiptap/extensions/status.ts +++ b/packages/client/src/components/tiptap/extensions/status.ts @@ -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 { 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 diff --git a/packages/client/src/components/tiptap/extensions/table.ts b/packages/client/src/components/tiptap/extensions/table.ts index 83aeb7ed..097b4dec 100644 --- a/packages/client/src/components/tiptap/extensions/table.ts +++ b/packages/client/src/components/tiptap/extensions/table.ts @@ -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, }); diff --git a/packages/client/src/components/tiptap/extensions/tableCell.tsx b/packages/client/src/components/tiptap/extensions/tableCell.tsx index b93e9cfb..b4a8affc 100644 --- a/packages/client/src/components/tiptap/extensions/tableCell.tsx +++ b/packages/client/src/components/tiptap/extensions/tableCell.tsx @@ -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); } } } diff --git a/packages/client/src/components/tiptap/extensions/tableHeader.tsx b/packages/client/src/components/tiptap/extensions/tableHeader.tsx index 2f2485ee..f9b4a356 100644 --- a/packages/client/src/components/tiptap/extensions/tableHeader.tsx +++ b/packages/client/src/components/tiptap/extensions/tableHeader.tsx @@ -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({ }} /> - +