feat: improve tiptap

This commit is contained in:
fantasticit 2022-03-20 21:21:16 +08:00
parent cc30e00984
commit 1835f8504b
29 changed files with 1313 additions and 38 deletions

View File

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

View File

@ -0,0 +1,16 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconHorizontalRule: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="none" fill-rule="evenodd">
<rect fill="currentColor" x="17" y="116" width="223" height="24" rx="12"></rect>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,21 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconList: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="currentColor" fill-rule="evenodd">
<g fill-rule="nonzero">
<path d="M215 118c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L215 138H96c-5.523 0-10-4.477-10-10 0-5.43 4.327-9.848 9.72-9.996L96 118h119ZM215 35c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L215 55H96c-5.523 0-10-4.477-10-10 0-5.43 4.327-9.848 9.72-9.996L96 35h119ZM215 201c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L215 221H96c-5.523 0-10-4.477-10-10 0-5.43 4.327-9.848 9.72-9.996L96 201h119Z"></path>
</g>
<circle cx="44" cy="45" r="16"></circle>
<circle cx="44" cy="128" r="16"></circle>
<circle cx="44" cy="211" r="16"></circle>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,16 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconOrderedList: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="currentColor" fill-rule="nonzero">
<path d="M215 118c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L215 138H101c-5.523 0-10-4.477-10-10 0-5.43 4.327-9.848 9.72-9.996L101 118h114ZM215 35c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L215 55H101c-5.523 0-10-4.477-10-10 0-5.43 4.327-9.848 9.72-9.996L101 35h114ZM215 201c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L215 221H101c-5.523 0-10-4.477-10-10 0-5.43 4.327-9.848 9.72-9.996L101 201h114ZM65.988 190.061l-.739.79-1.88 2.061-4.394 4.885-6.435 7.205h13.852a9 9 0 0 1 8.996 8.736l.004.265a9 9 0 0 1-8.735 8.996l-.265.004H32.5c-7.762 0-11.883-9.167-6.732-14.973l15.666-17.621 6.446-7.197 3.323-3.665 1.424-1.54.623-.653.408-.407.042-.039c1.203-1.106 1.855-2.536 1.855-4.05 0-3.128-2.87-5.855-6.637-5.855-3.681 0-6.508 2.607-6.632 5.647l-.004.208a9 9 0 1 1-18 0c0-13.281 11.13-23.855 24.636-23.855 13.507 0 24.637 10.574 24.637 23.855 0 6.497-2.703 12.572-7.355 17.003l-.212.2ZM43.206 25.413c5.694-5.302 14.934-1.363 15.13 6.337l.003.25v73a9 9 0 0 1-17.996.265L40.34 105V52.583c-3.556 2.568-8.513 2.208-11.653-.96l-.198-.207a9 9 0 0 1 .247-12.522l.206-.198 14.265-13.283Z"></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,31 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconQuote: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="none" fill-rule="evenodd">
<g fill="currentColor">
<path d="M36 143h44c6.627 0 12 5.373 12 12v44c0 6.627-5.373 12-12 12H36c-6.627 0-12-5.373-12-12v-44c0-6.627 5.373-12 12-12Z"></path>
<path
d="M84.144 53.257c5.502-.485 10.355 3.582 10.84 9.084.476 5.408-3.447 10.19-8.806 10.81l-.279.029c-23.384 2.06-41.533 21.544-41.894 45.105L44 119v41.723c0 5.523-4.477 10-10 10-5.43 0-9.848-4.326-9.996-9.72l-.004-.28V119c0-34.269 26.22-62.755 60.144-65.743Z"
fill-rule="nonzero"
></path>
<path d="M43.272 120.769 38.56 142.58a9.567 9.567 0 0 0 13.93 10.42l11.401-6.216a2.015 2.015 0 0 0-.964-3.784h-2.933c-8.837 0-16-7.164-16-16v-6.155a.365.365 0 0 0-.722-.077Z"></path>
</g>
<g fill="currentColor">
<path d="M176 143h44c6.627 0 12 5.373 12 12v44c0 6.627-5.373 12-12 12h-44c-6.627 0-12-5.373-12-12v-44c0-6.627 5.373-12 12-12Z"></path>
<path
d="M224.144 53.257c5.502-.485 10.355 3.582 10.84 9.084.476 5.408-3.447 10.19-8.806 10.81l-.279.029c-23.384 2.06-41.533 21.544-41.894 45.105L184 119v41.723c0 5.523-4.477 10-10 10-5.43 0-9.848-4.326-9.996-9.72l-.004-.28V119c0-34.269 26.22-62.755 60.144-65.743Z"
fill-rule="nonzero"
></path>
<path d="m183.272 120.769-4.712 21.812a9.567 9.567 0 0 0 13.93 10.42l11.401-6.216a2.015 2.015 0 0 0-.964-3.784h-2.933c-8.837 0-16-7.164-16-16v-6.155a.365.365 0 0 0-.722-.077Z"></path>
</g>
</g>
</svg>
}
/>
);
};

View File

@ -5,12 +5,19 @@ export const IconTask: React.FC<{ style?: React.CSSProperties }> = ({ style = {}
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="none" fill-rule="evenodd">
<path
d="M7.5 6h9A1.5 1.5 0 0118 7.5v9a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 016 16.5v-9A1.5 1.5 0 017.5 6zm3.072 8.838l.143.154a.5.5 0 00.769-.042l.13-.175 3.733-5.045a.8.8 0 00-.11-1.064.665.665 0 00-.984.118l-3.243 4.387-1.315-1.422a.663.663 0 00-.99 0 .801.801 0 000 1.07l1.867 2.019z"
d="M169.002 89.757c3.945-3.865 10.276-3.8 14.141.145 3.795 3.873 3.801 10.047.067 13.928l-.212.213-63.285 62a10 10 0 0 1-13.776.209l-.219-.208-32.715-32.041c-3.946-3.865-4.012-10.196-.147-14.142 3.794-3.873 9.966-4.007 13.924-.354l.217.207 25.717 25.187 56.288-55.144Z"
fill="currentColor"
fillRule="evenodd"
fill-rule="nonzero"
></path>
<path
d="M201 23c17.673 0 32 14.327 32 32v146c0 17.673-14.327 32-32 32H55c-17.673 0-32-14.327-32-32V55c0-17.673 14.327-32 32-32h146Zm0 20H55c-6.525 0-11.834 5.209-11.996 11.695L43 55v146c0 6.525 5.209 11.834 11.695 11.996L55 213h146c6.525 0 11.834-5.209 11.996-11.695L213 201V55c0-6.525-5.209-11.834-11.695-11.996L201 43Z"
fill="currentColor"
fill-rule="nonzero"
></path>
</g>
</svg>
}
/>

View File

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

View File

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

View File

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

View File

@ -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 (
<NodeViewWrapper as="div" className={styles.wrap}>
<NodeViewWrapper as="div" className={cls(styles.wrap, isEditable && styles.isEditable)}>
<div>
<div>
<Text type="tertiary"></Text>

View File

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

View File

@ -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 (
<NodeViewWrapper as="div" className={styles.wrap}>
<NodeViewWrapper as="div" className={cls(styles.wrap, isEditable && styles.isEditable)}>
<div>
{isEditable && (
<DataRender

View File

@ -4,7 +4,7 @@ import scrollIntoView from 'scroll-into-view-if-needed';
import styles from './index.module.scss';
interface IProps {
items: any[];
items: Array<{ name: string; emoji: string; fallbackImage?: string }>;
command: any;
}
@ -35,6 +35,7 @@ export const EmojiList: React.FC<IProps> = 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,7 +64,8 @@ export const EmojiList: React.FC<IProps> = forwardRef((props, ref) => {
return (
<div className={styles.items}>
<div ref={$container}>
{props.items.map((item, index) => (
{props.items.length ? (
props.items.map((item, index) => (
<button
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
key={index}
@ -71,7 +73,10 @@ export const EmojiList: React.FC<IProps> = forwardRef((props, ref) => {
>
{item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji}:{item.name}:
</button>
))}
))
) : (
<div className={styles.item}></div>
)}
</div>
</div>
);

View File

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

View File

@ -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<IProps> = forwardRef((props, ref) => {
const $container = useRef<HTMLDivElement>();
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 (
<div className={styles.items}>
<div ref={$container}>
{props.items.length ? (
props.items.map((item, index) => (
<button
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
key={index}
onClick={() => selectItem(index)}
>
{typeof item.label === 'function' ? item.label(props.editor) : item.label}
</button>
))
) : (
<div className={styles.item}></div>
)}
</div>
</div>
);
});

View File

@ -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 = <Tag color={color}>{text || '点击设置状态'}</Tag>;
console.log(node.attrs);
return (
<NodeViewWrapper as="div">
<table>
<NodeViewContent></NodeViewContent>
</table>
</NodeViewWrapper>
);
};

View File

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

View File

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

View File

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

View File

@ -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: (
<Space>
<IconList />
</Space>
),
command: (editor) => editor.chain().focus().toggleBulletList().run(),
},
{
key: '有序列表',
label: (
<Space>
<IconOrderedList />
</Space>
),
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
key: '任务列表',
label: (
<Space>
<IconTask />
</Space>
),
command: (editor) => editor.chain().focus().toggleTaskList().run(),
},
{
key: '链接',
label: (
<Space>
<IconLink />
</Space>
),
command: (editor) => editor.chain().focus().toggleLink().run(),
},
{
key: '引用',
label: (
<Space>
<IconQuote />
</Space>
),
command: (editor) => editor.chain().focus().toggleBlockquote().run(),
},
{
key: '分割线',
label: (
<Space>
<IconHorizontalRule />
线
</Space>
),
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
},
{
key: '表格',
label: (
<Space>
<IconTable />
</Space>
),
command: (editor) =>
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
{
key: '代码块',
label: (
<Space>
<IconCodeBlock />
</Space>
),
command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
key: '图片',
label: (editor) => (
<Space>
<IconImage />
<Upload
accept="image/*"
onOK={async (url, fileName) => {
const { width, height } = await getImageOriginSize(url);
console.log('upload', width, height);
editor.chain().focus().setImage({ src: url, alt: fileName, width, height }).run();
}}
>
{() => '图片'}
</Upload>
</Space>
),
command: (editor) => {},
},
{
key: '附件',
label: (editor) => (
<Space>
<IconAttachment />
<Upload
onOK={(url, name) => {
editor.chain().focus().setAttachment({ url, name }).run();
}}
>
{() => '附件'}
</Upload>
</Space>
),
command: (editor) => {},
},
{
key: '外链',
label: (
<Space>
<IconLink />
</Space>
),
command: (editor) => editor.chain().focus().insertIframe({ url: '' }).run(),
},
{
key: '思维导图',
label: (
<Space>
<IconMind />
</Space>
),
command: (editor) => editor.chain().focus().insertMind().run(),
},
{
key: '数学公式',
label: (
<Space>
<IconMath />
</Space>
),
command: (editor) => editor.chain().focus().setKatex().run(),
},
{
key: '状态',
label: (
<Space>
<IconStatus />
</Space>
),
command: (editor) => editor.chain().focus().setStatus().run(),
},
{
key: '信息框',
label: (
<Space>
<IconInfo />
</Space>
),
command: (editor) => editor.chain().focus().setBanner({ type: 'info' }).run(),
},
{
key: '文档',
label: (
<Space>
<IconDocument />
</Space>
),
command: (editor) => editor.chain().focus().setDocumentReference().run(),
},
{
key: '子文档',
label: (
<Space>
<IconDocument />
</Space>
),
command: (editor) => editor.chain().focus().setDocumentChildren().run(),
},
];
export const EvokeMenu = Node.create({
name: 'evokeMenu',
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();
},
};
},
},
});

View File

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

View File

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

View File

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

View File

@ -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 }) => {
<Button
theme={'borderless'}
type="tertiary"
icon={<IconCheckboxIndeterminate />}
icon={<IconHorizontalRule />}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
disabled={isTitleActive(editor)}
/>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons';
import { IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons';
import { IconOrderedList, IconList } from 'components/icons';
import { Tooltip } from 'components/tooltip';
import { IconTask } from 'components/icons';
import { isTitleActive } from '../services/isActive';

View File

@ -48,7 +48,7 @@ export const TableBubbleMenu = ({ editor }) => {
size="small"
/>
</Tooltip>
<Tooltip content="删除当前列">
<Tooltip content="删除当前列" hideOnClick>
<Button
onClick={() => editor.chain().focus().deleteColumn().run()}
icon={<IconDeleteColumn />}
@ -78,7 +78,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="删除当前行">
<Tooltip content="删除当前行" hideOnClick>
<Button
onClick={() => editor.chain().focus().deleteRow().run()}
icon={<IconDeleteRow />}

View File

@ -0,0 +1,228 @@
import { findParentNode } from '@tiptap/core';
import { Selection, Transaction } from 'prosemirror-state';
import { CellSelection, TableMap } from 'prosemirror-tables';
import { Node, ResolvedPos } from 'prosemirror-model';
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
);
for (let i = 0, count = cells.length; i < count; i++) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
}
}
return true;
};
export const findTable = (selection: Selection) =>
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
selection
);
export const isCellSelection = (selection: any) => {
return selection instanceof CellSelection;
};
export const isColumnSelected = (columnIndex: number) => (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1,
})(selection);
}
return false;
};
export const isTableSelected = (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
return indexes.reduce((acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
}, [] as { pos: number; start: number; node: Node | null | undefined }[]);
}
};
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
return indexes.reduce((acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
}, [] as { pos: number; start: number; node: Node | null | undefined }[]);
}
};
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
});
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
});
}
};
export const findParentNodeClosestToPos = (
$pos: ResolvedPos,
predicate: (node: Node) => boolean
) => {
for (let i = $pos.depth; i > 0; i--) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
};
}
}
};
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) =>
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === 'row';
if (table) {
const map = TableMap.get(table.node);
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom,
});
const cellsInLastRow =
bottom - top === 1
? cellsInFirstRow
: map.cellsInRect({
left: isRowSelection ? left : right - 1,
top: isRowSelection ? bottom - 1 : top,
right,
bottom,
});
const head = table.start + cellsInFirstRow[0];
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
// @ts-ignore
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select('column');
export const selectRow = select('row');
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
// @ts-ignore
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};

View File

@ -50,6 +50,14 @@
transform: translate(10px, 10px);
}
.placeholder::before {
content: attr(data-placeholder);
float: left;
color: #aaa;
pointer-events: none;
height: 0;
}
.hr-line {
width: 100%;
height: 2px;
@ -102,6 +110,49 @@
border-bottom: 1px solid var(--semi-color-border);
}
h1,
h2,
h3,
h4,
h5,
h6 {
cursor: text;
&::before {
display: inline-block;
font-family: var(--tiptap-font-family-mono);
color: var(--tiptap-color-text-secondly);
font-size: 13px;
line-height: 0;
margin-left: -24px;
width: 24px;
}
}
h1:not(.title):before {
content: 'H1';
}
h2::before {
content: 'H2';
}
h3::before {
content: 'H3';
}
h4::before {
content: 'H4';
}
h5::before {
content: 'H5';
}
h6::before {
content: 'H6';
}
h1 {
font-size: 1.87em;
margin: 26px 0 12px 0;
@ -234,18 +285,21 @@
.tableWrapper {
width: 100%;
overflow: auto;
padding: 1em 0 0 1em;
}
table {
border-collapse: collapse;
margin: 14px 0;
overflow: hidden;
table-layout: fixed;
width: 100%;
margin: 14px 0;
overflow: visible;
td,
th {
border: 1px solid var(--semi-color-border);
border-width: 1px;
border-style: solid;
border-color: var(--semi-color-fill-2);
box-sizing: border-box;
min-width: 1em;
padding: 3px 5px;
@ -263,7 +317,76 @@
}
.selectedCell {
background: var(--semi-color-fill-0);
border-style: double;
border-color: var(--semi-color-info);
background: var(--semi-color-info-light-hover);
}
.grip-column {
&::after {
content: '';
position: absolute;
display: block;
width: 100%;
height: 0.7em;
left: 0;
top: -1em;
margin-bottom: 3px;
cursor: pointer;
background: #ced4da;
}
&:hover::after {
background: var(--semi-color-info);
}
&.selected::after {
background: var(--semi-color-info);
}
}
.grip-row {
&::after {
content: '';
position: absolute;
display: block;
height: 100%;
width: 0.7em;
top: 0;
left: -1em;
margin-right: 3px;
cursor: pointer;
background: #ced4da;
}
&:hover::after {
background: var(--semi-color-info);
}
&.selected::after {
background: var(--semi-color-info);
}
}
.grip-table {
&::after {
content: '';
position: absolute;
display: block;
width: 0.8em;
height: 0.8em;
top: -1em;
left: -1em;
border-radius: 50%;
cursor: pointer;
background: #ced4da;
}
&:hover::after {
background: var(--semi-color-info);
}
&.selected::after {
background: var(--semi-color-info);
}
}
.column-resize-handle {

View File

@ -108,6 +108,7 @@ importers:
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
@ -183,7 +184,8 @@ importers:
marked: 4.0.12
next: 12.0.10_react-dom@17.0.2+react@17.0.2
prosemirror-markdown: 1.7.0
prosemirror-utils: 0.9.6
prosemirror-tables: 1.1.1
prosemirror-utils: 0.9.6_prosemirror-tables@1.1.1
prosemirror-view: 1.23.6
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
@ -6840,12 +6842,14 @@ packages:
prosemirror-model: 1.16.1
dev: false
/prosemirror-utils/0.9.6:
/prosemirror-utils/0.9.6_prosemirror-tables@1.1.1:
resolution: {integrity: sha512-UC+j9hQQ1POYfMc5p7UFxBTptRiGPR7Kkmbl3jVvU8VgQbkI89tR/GK+3QYC8n+VvBZrtAoCrJItNhWSxX3slA==}
peerDependencies:
prosemirror-model: ^1.0.0
prosemirror-state: ^1.0.1
prosemirror-tables: ^0.9.1
dependencies:
prosemirror-tables: 1.1.1
dev: false
/prosemirror-view/1.23.6: