From 1835f8504b2d4b29a1dad9e85f5695c5308ba817 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Sun, 20 Mar 2022 21:21:16 +0800 Subject: [PATCH] feat: improve tiptap --- packages/client/package.json | 1 + .../components/icons/IconHorizontalRule.tsx | 16 + .../client/src/components/icons/IconList.tsx | 21 + .../src/components/icons/IconOrderedList.tsx | 16 + .../client/src/components/icons/IconQuote.tsx | 31 ++ .../client/src/components/icons/IconTask.tsx | 19 +- .../client/src/components/icons/index.tsx | 4 + .../client/src/components/tiptap/basekit.tsx | 2 + .../documentChildren/index.module.scss | 13 + .../components/documentChildren/index.tsx | 6 +- .../documentReference/index.module.scss | 13 + .../components/documentReference/index.tsx | 3 +- .../tiptap/components/emojiList/index.tsx | 25 +- .../components/menuList/index.module.scss | 39 ++ .../tiptap/components/menuList/index.tsx | 85 ++++ .../tiptap/components/table/index.tsx | 18 + .../tiptap/extensions/documentChildren.ts | 2 +- .../tiptap/extensions/documentReference.ts | 2 +- .../src/components/tiptap/extensions/emoji.ts | 52 ++- .../tiptap/extensions/evokeMenu.tsx | 383 ++++++++++++++++++ .../tiptap/extensions/placeholder.ts | 6 +- .../components/tiptap/extensions/tableCell.ts | 121 +++++- .../tiptap/extensions/tableHeader.ts | 95 ++++- .../components/tiptap/menus/base-insert.tsx | 4 +- .../src/components/tiptap/menus/list.tsx | 3 +- .../src/components/tiptap/menus/table.tsx | 4 +- .../src/components/tiptap/services/table.ts | 228 +++++++++++ packages/client/src/styles/prosemirror.scss | 131 +++++- pnpm-lock.yaml | 8 +- 29 files changed, 1313 insertions(+), 38 deletions(-) create mode 100644 packages/client/src/components/icons/IconHorizontalRule.tsx create mode 100644 packages/client/src/components/icons/IconList.tsx create mode 100644 packages/client/src/components/icons/IconOrderedList.tsx create mode 100644 packages/client/src/components/icons/IconQuote.tsx create mode 100644 packages/client/src/components/tiptap/components/menuList/index.module.scss create mode 100644 packages/client/src/components/tiptap/components/menuList/index.tsx create mode 100644 packages/client/src/components/tiptap/components/table/index.tsx create mode 100644 packages/client/src/components/tiptap/extensions/evokeMenu.tsx create mode 100644 packages/client/src/components/tiptap/services/table.ts diff --git a/packages/client/package.json b/packages/client/package.json index 59538cfb..1a0511e8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -72,6 +72,7 @@ "marked": "^4.0.12", "next": "12.0.10", "prosemirror-markdown": "^1.7.0", + "prosemirror-tables": "^1.1.1", "prosemirror-utils": "^0.9.6", "prosemirror-view": "^1.23.6", "react": "17.0.2", diff --git a/packages/client/src/components/icons/IconHorizontalRule.tsx b/packages/client/src/components/icons/IconHorizontalRule.tsx new file mode 100644 index 00000000..3bc93822 --- /dev/null +++ b/packages/client/src/components/icons/IconHorizontalRule.tsx @@ -0,0 +1,16 @@ +import { Icon } from '@douyinfe/semi-ui'; + +export const IconHorizontalRule: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => { + return ( + + + + + + } + /> + ); +}; diff --git a/packages/client/src/components/icons/IconList.tsx b/packages/client/src/components/icons/IconList.tsx new file mode 100644 index 00000000..e71a8561 --- /dev/null +++ b/packages/client/src/components/icons/IconList.tsx @@ -0,0 +1,21 @@ +import { Icon } from '@douyinfe/semi-ui'; + +export const IconList: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => { + return ( + + + + + + + + + + + } + /> + ); +}; diff --git a/packages/client/src/components/icons/IconOrderedList.tsx b/packages/client/src/components/icons/IconOrderedList.tsx new file mode 100644 index 00000000..3e376954 --- /dev/null +++ b/packages/client/src/components/icons/IconOrderedList.tsx @@ -0,0 +1,16 @@ +import { Icon } from '@douyinfe/semi-ui'; + +export const IconOrderedList: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => { + return ( + + + + + + } + /> + ); +}; diff --git a/packages/client/src/components/icons/IconQuote.tsx b/packages/client/src/components/icons/IconQuote.tsx new file mode 100644 index 00000000..353478d3 --- /dev/null +++ b/packages/client/src/components/icons/IconQuote.tsx @@ -0,0 +1,31 @@ +import { Icon } from '@douyinfe/semi-ui'; + +export const IconQuote: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => { + return ( + + + + + + + + + + + + + + + } + /> + ); +}; diff --git a/packages/client/src/components/icons/IconTask.tsx b/packages/client/src/components/icons/IconTask.tsx index 35002acc..aa59d3f3 100644 --- a/packages/client/src/components/icons/IconTask.tsx +++ b/packages/client/src/components/icons/IconTask.tsx @@ -5,12 +5,19 @@ export const IconTask: React.FC<{ style?: React.CSSProperties }> = ({ style = {} - + + + + + } /> diff --git a/packages/client/src/components/icons/index.tsx b/packages/client/src/components/icons/index.tsx index 88212a14..3cba93b4 100644 --- a/packages/client/src/components/icons/index.tsx +++ b/packages/client/src/components/icons/index.tsx @@ -35,3 +35,7 @@ export * from './IconAttachment'; export * from './IconMath'; export * from './IconSearch'; export * from './IconSearchReplace'; +export * from './IconQuote'; +export * from './IconHorizontalRule'; +export * from './IconOrderedList'; +export * from './IconList'; diff --git a/packages/client/src/components/tiptap/basekit.tsx b/packages/client/src/components/tiptap/basekit.tsx index cdb522b1..e4fb2f5f 100644 --- a/packages/client/src/components/tiptap/basekit.tsx +++ b/packages/client/src/components/tiptap/basekit.tsx @@ -12,6 +12,7 @@ import { DocumentChildren } from './extensions/documentChildren'; import { DocumentReference } from './extensions/documentReference'; import { Dropcursor } from './extensions/dropCursor'; import { Emoji } from './extensions/emoji'; +import { EvokeMenu } from './extensions/evokeMenu'; import { FontSize } from './extensions/fontSize'; import { FootnoteDefinition } from './extensions/footnoteDefinition'; import { FootnoteReference } from './extensions/footnoteReference'; @@ -65,6 +66,7 @@ export const BaseKit = [ DocumentReference, Dropcursor, Emoji, + EvokeMenu, FontSize, FootnoteDefinition, FootnoteReference, diff --git a/packages/client/src/components/tiptap/components/documentChildren/index.module.scss b/packages/client/src/components/tiptap/components/documentChildren/index.module.scss index 5da0173f..4663de2b 100644 --- a/packages/client/src/components/tiptap/components/documentChildren/index.module.scss +++ b/packages/client/src/components/tiptap/components/documentChildren/index.module.scss @@ -3,6 +3,19 @@ padding-top: 12px; border-top: 1px solid var(--semi-color-border); + &.isEditable { + &:hover { + outline: 1px solid var(--semi-color-link); + } + + .itemWrap { + &:hover { + color: var(--semi-color-text-1); + border-color: var(--semi-color-border); + } + } + } + .itemWrap { display: flex; align-items: center; diff --git a/packages/client/src/components/tiptap/components/documentChildren/index.tsx b/packages/client/src/components/tiptap/components/documentChildren/index.tsx index 0f1545ae..31d7d6b5 100644 --- a/packages/client/src/components/tiptap/components/documentChildren/index.tsx +++ b/packages/client/src/components/tiptap/components/documentChildren/index.tsx @@ -1,6 +1,7 @@ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { useRouter } from 'next/router'; import Link from 'next/link'; +import cls from 'classnames'; import { Typography } from '@douyinfe/semi-ui'; import { useChildrenDocument } from 'data/document'; import { DataRender } from 'components/data-render'; @@ -10,7 +11,8 @@ import styles from './index.module.scss'; const { Text } = Typography; -export const DocumentChildrenWrapper = () => { +export const DocumentChildrenWrapper = ({ editor }) => { + const isEditable = editor.isEditable; const { pathname, query } = useRouter(); const wikiId = query?.wikiId; const documentId = query?.documentId; @@ -18,7 +20,7 @@ export const DocumentChildrenWrapper = () => { const { data: documents, loading, error } = useChildrenDocument({ wikiId, documentId, isShare }); return ( - +
子文档 diff --git a/packages/client/src/components/tiptap/components/documentReference/index.module.scss b/packages/client/src/components/tiptap/components/documentReference/index.module.scss index 698efe10..80d2876b 100644 --- a/packages/client/src/components/tiptap/components/documentReference/index.module.scss +++ b/packages/client/src/components/tiptap/components/documentReference/index.module.scss @@ -1,6 +1,19 @@ .wrap { margin: 8px 0; + &.isEditable { + &:hover { + outline: 1px solid var(--semi-color-link); + } + + .itemWrap { + &:hover { + color: var(--semi-color-text-1); + border-color: var(--semi-color-border); + } + } + } + .itemWrap { display: flex; align-items: center; diff --git a/packages/client/src/components/tiptap/components/documentReference/index.tsx b/packages/client/src/components/tiptap/components/documentReference/index.tsx index 4fafe00f..434633f2 100644 --- a/packages/client/src/components/tiptap/components/documentReference/index.tsx +++ b/packages/client/src/components/tiptap/components/documentReference/index.tsx @@ -1,6 +1,7 @@ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { useRouter } from 'next/router'; import Link from 'next/link'; +import cls from 'classnames'; import { Select } from '@douyinfe/semi-ui'; import { useWikiTocs } from 'data/wiki'; import { DataRender } from 'components/data-render'; @@ -21,7 +22,7 @@ export const DocumentReferenceWrapper = ({ editor, node, updateAttributes }) => }; return ( - +
{isEditable && ( ; command: any; } @@ -35,6 +35,7 @@ export const EmojiList: React.FC = forwardRef((props, ref) => { useEffect(() => setSelectedIndex(0), [props.items]); useEffect(() => { + if (Number.isNaN(selectedIndex + 1)) return; const el = $container.current.querySelector(`button:nth-of-type(${selectedIndex + 1})`); el && scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' }); }, [selectedIndex]); @@ -63,15 +64,19 @@ export const EmojiList: React.FC = forwardRef((props, ref) => { return (
- {props.items.map((item, index) => ( - - ))} + {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
没有找到结果
+ )}
); diff --git a/packages/client/src/components/tiptap/components/menuList/index.module.scss b/packages/client/src/components/tiptap/components/menuList/index.module.scss new file mode 100644 index 00000000..5d80813b --- /dev/null +++ b/packages/client/src/components/tiptap/components/menuList/index.module.scss @@ -0,0 +1,39 @@ +.items { + width: 160px; + max-height: 50vh; + overflow: auto; + padding: 0.2rem; + position: relative; + border-radius: 0.5rem; + font-size: 0.9rem; + color: var(--semi-color-text-0); + border-radius: var(--semi-border-radius-medium); + background-color: var(--semi-color-bg-0); + border: 1px solid var(--semi-color-border); +} + +.item { + display: block; + margin: 0; + width: 100%; + text-align: left; + background: transparent; + border-radius: 0.4rem; + border: 1px solid transparent; + padding: 0.2rem 0.4rem; + color: inherit; + cursor: pointer; + + &:hover { + border-color: var(--semi-color-info); + } + + &.is-selected { + border-color: var(--semi-color-info); + } + + img { + width: 1em; + height: 1em; + } +} diff --git a/packages/client/src/components/tiptap/components/menuList/index.tsx b/packages/client/src/components/tiptap/components/menuList/index.tsx new file mode 100644 index 00000000..0b17fd14 --- /dev/null +++ b/packages/client/src/components/tiptap/components/menuList/index.tsx @@ -0,0 +1,85 @@ +import { Editor } from '@tiptap/core'; +import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react'; +import cls from 'classnames'; +import scrollIntoView from 'scroll-into-view-if-needed'; +import styles from './index.module.scss'; + +interface IProps { + editor: Editor; + items: Array<{ label: React.ReactNode | ((editor: Editor) => React.ReactNode) }>; + command: any; +} + +export const MenuList: React.FC = forwardRef((props, ref) => { + const $container = useRef(); + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = (index) => { + const item = props.items[index]; + + if (item) { + props.command(item); + } + }; + + const upHandler = () => { + setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => setSelectedIndex(0), [props.items]); + + useEffect(() => { + if (Number.isNaN(selectedIndex + 1)) return; + const el = $container.current.querySelector(`button:nth-of-type(${selectedIndex + 1})`); + el && scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' }); + }, [selectedIndex]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; + }, + })); + + return ( +
+
+ {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
没有找到结果
+ )} +
+
+ ); +}); diff --git a/packages/client/src/components/tiptap/components/table/index.tsx b/packages/client/src/components/tiptap/components/table/index.tsx new file mode 100644 index 00000000..852790bb --- /dev/null +++ b/packages/client/src/components/tiptap/components/table/index.tsx @@ -0,0 +1,18 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; +import { Space, Popover, Tag, Input } from '@douyinfe/semi-ui'; + +export const TableWrapper = ({ editor, node, updateAttributes }) => { + const isEditable = editor.isEditable; + const { color, text } = node.attrs; + const content = {text || '点击设置状态'}; + + console.log(node.attrs); + + return ( + + + +
+
+ ); +}; diff --git a/packages/client/src/components/tiptap/extensions/documentChildren.ts b/packages/client/src/components/tiptap/extensions/documentChildren.ts index 19597fa5..19eb4356 100644 --- a/packages/client/src/components/tiptap/extensions/documentChildren.ts +++ b/packages/client/src/components/tiptap/extensions/documentChildren.ts @@ -15,8 +15,8 @@ export const DocumentChildrenInputRegex = /^documentChildren\$$/; export const DocumentChildren = Node.create({ name: 'documentChildren', group: 'block', - defining: true, draggable: true, + selectable: true, atom: true, addAttributes() { diff --git a/packages/client/src/components/tiptap/extensions/documentReference.ts b/packages/client/src/components/tiptap/extensions/documentReference.ts index 1837ab5b..35bd2f12 100644 --- a/packages/client/src/components/tiptap/extensions/documentReference.ts +++ b/packages/client/src/components/tiptap/extensions/documentReference.ts @@ -15,9 +15,9 @@ export const DocumentReferenceInputRegex = /^documentReference\$$/; export const DocumentReference = Node.create({ name: 'documentReference', group: 'block', - defining: true, draggable: true, atom: true, + selectable: true, addAttributes() { return { diff --git a/packages/client/src/components/tiptap/extensions/emoji.ts b/packages/client/src/components/tiptap/extensions/emoji.ts index 69abe659..1c4cbbf6 100644 --- a/packages/client/src/components/tiptap/extensions/emoji.ts +++ b/packages/client/src/components/tiptap/extensions/emoji.ts @@ -1,6 +1,7 @@ -import { Node } from '@tiptap/core'; +import { Node, findParentNode } from '@tiptap/core'; import { ReactRenderer } from '@tiptap/react'; -import { PluginKey } from 'prosemirror-state'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; import Suggestion from '@tiptap/suggestion'; import tippy from 'tippy.js'; import { EmojiList } from '../components/emojiList'; @@ -41,11 +42,48 @@ export const Emoji = Node.create({ }, addProseMirrorPlugins() { + const { editor } = this; + return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), + + new Plugin({ + key: new PluginKey('emojiPlaceholder'), + props: { + decorations: (state) => { + if (!editor.isEditable) return; + + const parent = findParentNode((node) => node.type.name === 'paragraph')( + state.selection + ); + if (!parent) { + return; + } + + const decorations: Decoration[] = []; + const isEmpty = parent && parent.node.content.size === 0; + const isSlash = parent && parent.node.textContent === ':'; + const isTopLevel = state.selection.$from.depth === 1; + + if (isTopLevel) { + if (isSlash) { + decorations.push( + Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, { + 'class': 'placeholder', + 'data-placeholder': ` 继续输入进行过滤`, + }) + ); + } + + return DecorationSet.create(state.doc, decorations); + } + return null; + }, + }, + }), ]; }, }).configure({ @@ -56,9 +94,13 @@ export const Emoji = Node.create({ render: () => { let component; let popup; + let isEditable; return { onStart: (props) => { + isEditable = props.editor.isEditable; + if (!isEditable) return; + component = new ReactRenderer(EmojiList, { props, editor: props.editor, @@ -76,6 +118,8 @@ export const Emoji = Node.create({ }, onUpdate(props) { + if (!isEditable) return; + component.updateProps(props); popup[0].setProps({ getReferenceClientRect: props.clientRect, @@ -83,6 +127,8 @@ export const Emoji = Node.create({ }, onKeyDown(props) { + if (!isEditable) return; + if (props.event.key === 'Escape') { popup[0].hide(); return true; @@ -91,6 +137,8 @@ export const Emoji = Node.create({ }, onExit() { + if (!isEditable) return; + popup[0].destroy(); component.destroy(); }, diff --git a/packages/client/src/components/tiptap/extensions/evokeMenu.tsx b/packages/client/src/components/tiptap/extensions/evokeMenu.tsx new file mode 100644 index 00000000..f9c665e4 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/evokeMenu.tsx @@ -0,0 +1,383 @@ +import { Node, findParentNode } from '@tiptap/core'; +import { ReactRenderer } from '@tiptap/react'; +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'; + +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', + + addOptions() { + return { + HTMLAttributes: {}, + suggestion: { + char: '/', + pluginKey: EvokeMenuPluginKey, + command: ({ editor, range, props }) => { + const { state, dispatch } = editor.view; + const $from = state.selection.$from; + const tr = state.tr.deleteRange($from.start(), $from.pos); + dispatch(tr); + props?.command(editor); + editor.view.focus(); + }, + }, + }; + }, + + addProseMirrorPlugins() { + const { editor } = this; + + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + + new Plugin({ + key: new PluginKey('evokeMenuPlaceholder'), + props: { + decorations: (state) => { + if (!editor.isEditable) return; + + const parent = findParentNode((node) => node.type.name === 'paragraph')( + state.selection + ); + if (!parent) { + return; + } + + const decorations: Decoration[] = []; + const isEmpty = parent && parent.node.content.size === 0; + const isSlash = parent && parent.node.textContent === '/'; + const isTopLevel = state.selection.$from.depth === 1; + + if (isTopLevel) { + if (isEmpty) { + decorations.push( + Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, { + 'class': 'placeholder', + 'data-placeholder': '输入 / 唤起更多', + }) + ); + } + + if (isSlash) { + decorations.push( + Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, { + 'class': 'placeholder', + 'data-placeholder': ` 继续输入进行过滤`, + }) + ); + } + + return DecorationSet.create(state.doc, decorations); + } + return null; + }, + }, + }), + ]; + }, +}).configure({ + suggestion: { + items: ({ query }) => { + return COMMANDS.filter((command) => command.key.startsWith(query)); + }, + render: () => { + let component; + let popup; + let isEditable; + + return { + onStart: (props) => { + isEditable = props.editor.isEditable; + if (!isEditable) return; + + component = new ReactRenderer(MenuList, { + props, + editor: props.editor, + }); + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + if (!isEditable) return; + + component.updateProps(props); + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (!isEditable) return; + + if (props.event.key === 'Escape') { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props); + }, + + onExit(props) { + if (!isEditable) return; + + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/placeholder.ts b/packages/client/src/components/tiptap/extensions/placeholder.ts index f71094cf..f2b86fe0 100644 --- a/packages/client/src/components/tiptap/extensions/placeholder.ts +++ b/packages/client/src/components/tiptap/extensions/placeholder.ts @@ -1,11 +1,13 @@ import BuiltInPlaceholder from '@tiptap/extension-placeholder'; export const Placeholder = BuiltInPlaceholder.configure({ - placeholder: ({ node }) => { + placeholder: ({ node, editor }) => { + if (!editor.isEditable) return; + if (node.type.name === 'title') { return '请输入标题'; } - return '请输入内容'; + return '输入 / 唤起更多'; }, showOnlyCurrent: false, showOnlyWhenEditable: true, diff --git a/packages/client/src/components/tiptap/extensions/tableCell.ts b/packages/client/src/components/tiptap/extensions/tableCell.ts index d24f72e9..12d1df9a 100644 --- a/packages/client/src/components/tiptap/extensions/tableCell.ts +++ b/packages/client/src/components/tiptap/extensions/tableCell.ts @@ -1 +1,120 @@ -export { TableCell } from '@tiptap/extension-table-cell'; +import { TableCell as BuiltInTable } from '@tiptap/extension-table-cell'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { + getCellsInColumn, + isRowSelected, + isTableSelected, + selectRow, + selectTable, +} from '../services/table'; + +export const TableCell = BuiltInTable.extend({ + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey(`${this.name}FloatMenu`), + // view: () => + // new FloatMenuView({ + // editor: this.editor, + // // has one selected should show + // shouldShow: ({ editor }) => { + // if (!editor.isEditable) { + // return false; + // } + // const cells = getCellsInColumn(0)(editor.state.selection); + // return !!cells?.some((cell, index) => + // isRowSelected(index)(editor.state.selection) + // ); + // }, + // init: (dom, editor) => { + // const insertTop = buttonView({ + // id: "insert-top", + // name: this.options.dictionary.insertTop, + // icon: DoubleUp({}), + // }); + // insertTop.button.addEventListener("click", () => { + // editor.chain().addRowBefore().run(); + // }); + // const insertBottom = buttonView({ + // id: "insert-bottom", + // name: this.options.dictionary.insertBottom, + // icon: DoubleDown({}), + // }); + // insertBottom.button.addEventListener("click", () => { + // editor.chain().addRowAfter().run(); + // }); + // const remove = buttonView({ + // name: this.options.dictionary.delete, + // icon: Delete({}), + // }); + // remove.button.addEventListener("click", () => { + // if (isTableSelected(editor.state.selection)) { + // editor.chain().deleteTable().run(); + // } else { + // editor.chain().deleteRow().run(); + // } + // }); + + // dom.append(insertTop.button); + // dom.append(insertBottom.button); + // dom.append(remove.button); + // }, + // }), + props: { + decorations: (state) => { + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInColumn(0)(selection); + + if (cells) { + cells.forEach(({ pos }, index) => { + if (index === 0) { + decorations.push( + Decoration.widget(pos + 1, () => { + const grip = document.createElement('a'); + grip.classList.add('grip-table'); + if (isTableSelected(selection)) { + grip.classList.add('selected'); + } + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + this.editor.view.dispatch(selectTable(this.editor.state.tr)); + }); + return grip; + }) + ); + } + decorations.push( + Decoration.widget(pos + 1, () => { + const rowSelected = isRowSelected(index)(selection); + const grip = document.createElement('a'); + grip.classList.add('grip-row'); + if (rowSelected) { + grip.classList.add('selected'); + } + if (index === 0) { + grip.classList.add('first'); + } + if (index === cells.length - 1) { + grip.classList.add('last'); + } + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + this.editor.view.dispatch(selectRow(index)(this.editor.state.tr)); + }); + return grip; + }) + ); + }); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/tableHeader.ts b/packages/client/src/components/tiptap/extensions/tableHeader.ts index e2acc4a9..8047fce5 100644 --- a/packages/client/src/components/tiptap/extensions/tableHeader.ts +++ b/packages/client/src/components/tiptap/extensions/tableHeader.ts @@ -1 +1,94 @@ -export { TableHeader } from '@tiptap/extension-table-header'; +import { TableHeader as BuiltInTableHeader } from '@tiptap/extension-table-header'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { getCellsInRow, isColumnSelected, isTableSelected, selectColumn } from '../services/table'; + +export const TableHeader = BuiltInTableHeader.extend({ + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey(`${this.name}FloatMenu`), + // view: () => + // new FloatMenuView({ + // editor: this.editor, + // // has one selected should show + // shouldShow: ({ editor }) => { + // if (!editor.isEditable) { + // return false; + // } + // const selection = editor.state.selection; + // if (isTableSelected(selection)) { + // return false; + // } + // const cells = getCellsInRow(0)(selection); + // return !!cells?.some((cell, index) => + // isColumnSelected(index)(selection) + // ); + // }, + // init: (dom, editor) => { + // const insertLeft = buttonView({ + // name: this.options.dictionary.insertLeft, + // icon: DoubleLeft({}), + // }); + // insertLeft.button.addEventListener("click", () => { + // editor.chain().addColumnBefore().run(); + // }); + // const insertRight = buttonView({ + // name: this.options.dictionary.insertRight, + // icon: DoubleRight({}), + // }); + // insertRight.button.addEventListener("click", () => { + // editor.chain().addColumnAfter().run(); + // }); + // const remove = buttonView({ + // name: this.options.dictionary.delete, + // icon: Delete({}), + // }); + // remove.button.addEventListener("click", () => { + // editor.chain().deleteColumn().run(); + // }); + + // dom.append(insertLeft.button); + // dom.append(insertRight.button); + // dom.append(remove.button); + // }, + // }), + props: { + decorations: (state) => { + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInRow(0)(selection); + + if (cells) { + cells.forEach(({ pos }, index) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const colSelected = isColumnSelected(index)(selection); + const grip = document.createElement('a'); + grip.classList.add('grip-column'); + if (colSelected) { + grip.classList.add('selected'); + } + if (index === 0) { + grip.classList.add('first'); + } else if (index === cells.length - 1) { + grip.classList.add('last'); + } + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr)); + }); + return grip; + }) + ); + }); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/menus/base-insert.tsx b/packages/client/src/components/tiptap/menus/base-insert.tsx index 24751cf1..76881af3 100644 --- a/packages/client/src/components/tiptap/menus/base-insert.tsx +++ b/packages/client/src/components/tiptap/menus/base-insert.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button } from '@douyinfe/semi-ui'; -import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons'; import { Tooltip } from 'components/tooltip'; +import { IconQuote, IconLink, IconHorizontalRule } from 'components/icons'; import { isTitleActive } from '../services/isActive'; import { Emoji } from './components/emoji'; import { Search } from './search'; @@ -40,7 +40,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {