feat: improve editor

This commit is contained in:
fantasticit 2022-03-20 17:46:29 +08:00
parent c8c370e0cb
commit cc30e00984
66 changed files with 3286 additions and 266 deletions

View File

@ -51,6 +51,7 @@
"@tiptap/extension-text-style": "^2.0.0-beta.23",
"@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/react": "^2.0.0-beta.107",
"@tiptap/suggestion": "^2.0.0-beta.90",
"@traptitech/markdown-it-katex": "^3.5.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
@ -62,6 +63,8 @@
"lowlight": "^2.5.0",
"markdown-it": "^12.3.2",
"markdown-it-anchor": "^8.4.1",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"markdown-it-footnote": "^3.0.3",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
@ -69,11 +72,13 @@
"marked": "^4.0.12",
"next": "12.0.10",
"prosemirror-markdown": "^1.7.0",
"prosemirror-utils": "^0.9.6",
"prosemirror-view": "^1.23.6",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-split-pane": "^0.1.92",
"scroll-into-view-if-needed": "^2.2.29",
"swr": "^1.2.0",
"tippy.js": "^6.3.7"
},

View File

@ -13,12 +13,12 @@ export const DocumentContent: React.FC<IProps> = ({ document }) => {
const c = safeJSONParse(document.content);
let json = c.default || c;
if (json && json.content) {
json = {
type: 'doc',
content: json.content.slice(1),
};
}
// if (json && json.content) {
// json = {
// type: 'doc',
// content: json.content.slice(1),
// };
// }
const editor = useEditor({
editable: false,

View File

@ -1,7 +1,7 @@
import React, { useMemo, useEffect } from 'react';
import React, { useMemo, useEffect, useRef } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import { Layout } from '@douyinfe/semi-ui';
import { ILoginUser } from '@think/domains';
import { IDocument, ILoginUser } from '@think/domains';
import { useToggle } from 'hooks/useToggle';
import {
DEFAULT_EXTENSION,
@ -13,6 +13,7 @@ import {
} from 'components/tiptap';
import { DataRender } from 'components/data-render';
import { joinUser } from 'components/document/collaboration';
import { CreateUser } from './user';
import styles from './index.module.scss';
const { Content } = Layout;
@ -20,11 +21,13 @@ const { Content } = Layout;
interface IProps {
user: ILoginUser;
documentId: string;
document: IDocument;
}
export const Editor: React.FC<IProps> = ({ user, documentId }) => {
export const Editor: React.FC<IProps> = ({ user, documentId, document }) => {
if (!user) return null;
const $ref = useRef();
const provider = useMemo(() => {
return getProvider({
targetId: documentId,
@ -68,7 +71,15 @@ export const Editor: React.FC<IProps> = ({ user, documentId }) => {
return (
<>
<Content className={styles.editorWrap}>
<EditorContent editor={editor} />
<div id="js-reader-container">
<EditorContent editor={editor} />
</div>
<CreateUser
document={document}
container={() =>
window.document.querySelector('#js-reader-container .ProseMirror .title')
}
/>
</Content>
</>
);

View File

@ -122,10 +122,12 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
return (
<>
<Seo title={document.title} />
<Editor key={document.id} user={user} documentId={document.id} />
<div style={{ marginBottom: 24 }}>
<CreateUser document={document} />
</div>
<Editor
key={document.id}
user={user}
documentId={document.id}
document={document}
/>
<div className={styles.commentWrap}>
<CommentEditor documentId={document.id} />
</div>

View File

@ -131,11 +131,15 @@ export const DocumentPublicReader: React.FC<IProps> = ({ documentId, hideLogo =
style={{ fontSize }}
id="js-share-document-editor-container"
>
<Title style={{ fontSize: '2.4em' }}>{data.title}</Title>
<div style={{ margin: '24px 0' }}>
<CreateUser document={data} />
</div>
<DocumentContent document={data} />
<CreateUser
document={data}
container={() =>
window.document.querySelector(
'#js-share-document-editor-container .ProseMirror .title'
)
}
/>
</div>
<BackTop
target={() =>

View File

@ -1,15 +1,26 @@
import { createPortal } from 'react-dom';
import { Space, Typography, Avatar } from '@douyinfe/semi-ui';
import { IconUser } from '@douyinfe/semi-icons';
import { IDocument } from '@think/domains';
import { LocaleTime } from 'components/locale-time';
const { Text } = Typography;
export const CreateUser: React.FC<{ document: IDocument }> = ({ document }) => {
export const CreateUser: React.FC<{ document: IDocument; container: () => HTMLElement }> = ({
document,
container = null,
}) => {
if (!document.createUser) return null;
return (
<Text type="tertiary" size="small">
const content = (
<div
style={{
borderTop: '1px solid var(--semi-color-border)',
marginTop: 24,
padding: '16px 0',
fontSize: 13,
fontWeight: 'normal',
color: 'var(--semi-color-text-0)',
}}
>
<Space>
<Avatar size="extra-extra-small" src={document.createUser && document.createUser.avatar}>
<IconUser />
@ -27,6 +38,11 @@ export const CreateUser: React.FC<{ document: IDocument }> = ({ document }) => {
</p>
</div>
</Space>
</Text>
</div>
);
const el = container && container();
if (!el) return content;
return createPortal(content, el);
};

View File

@ -0,0 +1,21 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconSearchReplace: 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="M191.527 15c18.302 0 33.173 14.688 33.469 32.919l.004.554v65.424c0 5.523-4.477 10-10 10-5.43 0-9.848-4.327-9.996-9.72l-.004-.28V48.473c0-7.338-5.865-13.305-13.163-13.47l-.31-.003H64.473c-7.338 0-13.305 5.865-13.47 13.163l-.003.31v159.054c0 7.338 5.865 13.305 13.163 13.47l.31.003H99c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L99 241H64.473c-18.302 0-33.173-14.688-33.469-32.919l-.004-.554V48.473C31 30.17 45.688 15.3 63.919 15.004l.554-.004h127.054Z"></path>
<path d="M147.385 150.885c-17.964 17.964-17.964 47.09 0 65.054s47.09 17.964 65.054 0c17.964-17.965 17.964-47.09 0-65.054-17.965-17.964-47.09-17.964-65.054 0Zm14.142 14.142c10.154-10.154 26.616-10.154 36.77 0 10.153 10.154 10.153 26.616 0 36.77-10.154 10.153-26.616 10.153-36.77 0-10.154-10.154-10.154-26.616 0-36.77Z"></path>
<path d="M234.545 241.752c-3.839 3.84-10.023 3.904-13.941.196l-.2-.196-21.921-21.92c-3.905-3.905-3.905-10.237 0-14.142 3.839-3.84 10.023-3.904 13.941-.195l.2.195 21.921 21.92c3.905 3.905 3.905 10.237 0 14.142Z"></path>
</g>
<path d="M92 71h72c5.523 0 10 4.477 10 10s-4.477 10-10 10H92c-5.523 0-10-4.477-10-10s4.477-10 10-10ZM92 125h26c5.523 0 10 4.477 10 10s-4.477 10-10 10H92c-5.523 0-10-4.477-10-10s4.477-10 10-10Z"></path>
</g>
</svg>
}
/>
);
};

View File

@ -34,3 +34,4 @@ export * from './IconSplitCell';
export * from './IconAttachment';
export * from './IconMath';
export * from './IconSearch';
export * from './IconSearchReplace';

View File

@ -11,6 +11,7 @@ import { ColorHighlighter } from './extensions/colorHighlighter';
import { DocumentChildren } from './extensions/documentChildren';
import { DocumentReference } from './extensions/documentReference';
import { Dropcursor } from './extensions/dropCursor';
import { Emoji } from './extensions/emoji';
import { FontSize } from './extensions/fontSize';
import { FootnoteDefinition } from './extensions/footnoteDefinition';
import { FootnoteReference } from './extensions/footnoteReference';
@ -33,6 +34,7 @@ import { Paragraph } from './extensions/paragraph';
import { PasteFile } from './extensions/pasteFile';
import { PasteMarkdown } from './extensions/pasteMarkdown';
import { Placeholder } from './extensions/placeholder';
import { SearchNReplace } from './extensions/search';
import { Status } from './extensions/status';
import { Strike } from './extensions/strike';
import { Table } from './extensions/table';
@ -62,6 +64,7 @@ export const BaseKit = [
DocumentChildren,
DocumentReference,
Dropcursor,
Emoji,
FontSize,
FootnoteDefinition,
FootnoteReference,
@ -83,15 +86,8 @@ export const BaseKit = [
Paragraph,
PasteFile,
PasteMarkdown,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'title') {
return '请输入标题';
}
return '请输入内容';
},
showOnlyWhenEditable: true,
}),
Placeholder,
SearchNReplace,
Status,
Strike,
Table,
@ -99,9 +95,7 @@ export const BaseKit = [
TableHeader,
TableRow,
Text,
TextAlign.configure({
types: ['heading', 'paragraph', 'image'],
}),
TextAlign,
TextStyle,
TaskItem,
TaskList,

View File

@ -1,6 +1,7 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui';
import { IconDownload } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { download } from '../../services/download';
import styles from './index.module.scss';
@ -12,7 +13,7 @@ export const AttachmentWrapper = ({ node }) => {
<div className={styles.wrap}>
<span>{name}</span>
<span>
<Tooltip zIndex={10000} content="下载">
<Tooltip content="下载">
<Button
theme={'borderless'}
type="tertiary"

View File

@ -1,10 +1,7 @@
import React, { useRef } from 'react';
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Button, Select, Tooltip } from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
// @ts-ignore
import { lowlight } from 'lowlight';
import { copy } from 'helpers/copy';
import styles from './index.module.scss';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
.items {
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,78 @@
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 {
items: any[];
command: any;
}
export const EmojiList: 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(() => {
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.map((item, index) => (
<button
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
key={index}
onClick={() => selectItem(index)}
>
{item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji}:{item.name}:
</button>
))}
</div>
</div>
);
});

View File

@ -21,7 +21,7 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => {
const content = text ? (
<span contentEditable={false} dangerouslySetInnerHTML={{ __html: formatText }}></span>
) : (
<span contentEditable={false}></span>
<span contentEditable={false}></span>
);
return (
@ -32,7 +32,7 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => {
content={
<div style={{ width: 320 }}>
<TextArea
autofocus
autoFocus
placeholder="输入公式"
autosize
rows={3}

View File

@ -1,7 +1,8 @@
import ReactDOM from 'react-dom';
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
import { Space, Button } from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { IconZoomOut, IconZoomIn } from 'components/icons';
import { Tooltip } from 'components/tooltip';
import { Divider } from '../divider';
import styles from './index.module.scss';

View File

@ -5,7 +5,7 @@ import styles from './index.module.scss';
export const StatusWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable;
const { color, text } = node.attrs;
const content = <Tag color={color}>{text || '设置状态'}</Tag>;
const content = <Tag color={color}>{text || '点击设置状态'}</Tag>;
return (
<NodeViewWrapper as="span" className={styles.wrap}>

View File

@ -1,6 +1,7 @@
import { Node, Command, mergeAttributes } from '@tiptap/core';
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { BannerWrapper } from '../components/banner';
import { typesAvailable } from '../services/markdown/markdownBanner';
declare module '@tiptap/core' {
interface Commands {
@ -12,33 +13,53 @@ declare module '@tiptap/core' {
export const Banner = Node.create({
name: 'banner',
content: 'block*',
content: 'paragraph+',
group: 'block',
defining: true,
draggable: true,
addOptions() {
return {
types: typesAvailable,
HTMLAttributes: {
class: 'banner',
},
};
},
addAttributes() {
return {
type: {
default: 'info',
rendered: false,
parseHTML: (element) => element.getAttribute('data-banner'),
renderHTML: (attributes) => {
return {
'data-banner': attributes.type,
'class': `banner banner-${attributes.type}`,
};
},
},
};
},
parseHTML() {
return [{ tag: 'div' }];
return [
{
tag: 'div',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
{ class: 'banner' },
[
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
0,
],
];
renderHTML({ node, HTMLAttributes }) {
const { class: classy } = this.options.HTMLAttributes;
const attributes = {
...this.options.HTMLAttributes,
'data-callout': node.attrs.type,
'class': `${classy} ${classy}-${node.attrs.type}`,
};
return ['div', mergeAttributes(attributes, HTMLAttributes), 0];
},
// @ts-ignore
@ -57,6 +78,18 @@ export const Banner = Node.create({
};
},
addInputRules() {
return [
wrappingInputRule({
find: /^:::([\dA-Za-z]*) $/,
type: this.type,
getAttributes: (match) => {
return { type: match[1] };
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(BannerWrapper);
},

View File

@ -1,7 +1,7 @@
import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote';
import { wrappingInputRule } from '@tiptap/core';
import { getParents } from '../services/dom';
import { getMarkdownSource } from '../services/markdownSourceMap';
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
export const Blockquote = BuiltInBlockquote.extend({
addAttributes() {

View File

@ -1,5 +1,6 @@
import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list';
import { getMarkdownSource } from '../services/markdownSourceMap';
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
import { listInputRule } from '../services/listInputRule';
export const BulletList = BuiltInBulletList.extend({
addAttributes() {
@ -16,4 +17,8 @@ export const BulletList = BuiltInBulletList.extend({
},
};
},
addInputRules() {
return [listInputRule(/^\s*([-+*])\s([^\s[])$/, this.type)];
},
});

View File

@ -0,0 +1,100 @@
import { Node } from '@tiptap/core';
import { ReactRenderer } from '@tiptap/react';
import { PluginKey } from 'prosemirror-state';
import Suggestion from '@tiptap/suggestion';
import tippy from 'tippy.js';
import { EmojiList } from '../components/emojiList';
import { emojiSearch, emojisToName } from '../components/emojiList/emojis';
export const EmojiPluginKey = new PluginKey('emoji');
export { emojisToName };
export const Emoji = Node.create({
name: 'emoji',
content: 'text*',
addOptions() {
return {
HTMLAttributes: {},
suggestion: {
char: ':',
pluginKey: EmojiPluginKey,
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.insertContentAt(range, props.emoji + ' ')
.run();
},
},
};
},
// @ts-ignore
addCommands() {
return {
emoji:
(emojiObject) =>
({ commands }) => {
return commands.insertContent(emojiObject.emoji + ' ');
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
}).configure({
suggestion: {
items: ({ query }) => {
return emojiSearch(query);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = new ReactRenderer(EmojiList, {
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) {
component.updateProps(props);
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
},
});

View File

@ -1,7 +1,7 @@
import { Command, Extension } from '@tiptap/core';
import { sinkListItem, liftListItem } from 'prosemirror-schema-list';
import { TextSelection, AllSelection, Transaction } from 'prosemirror-state';
import { isListActive } from '../services/active';
import { isListActive } from '../services/isActive';
import { clamp } from '../services/clamp';
import { getNodeType } from '../services/type';
import { isListNode } from '../services/node';

View File

@ -1,4 +1,4 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { Node, Command, mergeAttributes, nodeInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { KatexWrapper } from '../components/katex';
@ -55,7 +55,7 @@ export const Katex = Node.create({
addInputRules() {
return [
wrappingInputRule({
nodeInputRule({
find: KatexInputRegex,
type: this.type,
getAttributes: (match) => {

View File

@ -59,4 +59,8 @@ export const Link = BuiltInLink.extend({
},
};
},
}).configure({
openOnClick: false,
linkOnPaste: true,
autolink: true,
});

View File

@ -1,4 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { MindWrapper } from '../components/mind';
@ -81,4 +81,16 @@ export const Mind = Node.create({
addNodeView() {
return ReactNodeViewRenderer(MindWrapper);
},
addInputRules() {
return [
nodeInputRule({
find: /^\$mind $/,
type: this.type,
getAttributes: (match) => {
return { type: match[1] };
},
}),
];
},
});

View File

@ -1,5 +1,5 @@
import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list';
import { getMarkdownSource } from '../services/markdownSourceMap';
import { getMarkdownSource } from '../services/markdown/markdownSourceMap';
export const OrderedList = BuiltInOrderedList.extend({
addAttributes() {

View File

@ -1,56 +1,133 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import { markdownSerializer } from '../services/serializer';
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
// @ts-ignore
import { lowlight } from 'lowlight';
import { markdownSerializer } from '../services/markdown';
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
const TEXT_FORMAT = 'text/plain';
const HTML_FORMAT = 'text/html';
const VS_CODE_FORMAT = 'vscode-editor-data';
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',
priority: EXTENSION_PRIORITY_HIGHEST,
// @ts-ignore
addCommands() {
return {
pasteMarkdown: (markdown) => () => {
const { editor } = this;
const { state, view } = editor;
const { tr, selection } = state;
const document = markdownSerializer.deserialize({
schema: view.props.state.schema,
content: markdown,
});
// tr.replaceWith(selection.from - 1, selection.to, document.content);
// view.dispatch(tr);
const transaction = view.state.tr.replaceSelectionWith(document);
view.dispatch(transaction);
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
handlePaste: (_, event) => {
const { clipboardData } = event;
const content = clipboardData.getData(TEXT_FORMAT);
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
const language = vsCodeMeta.mode;
if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
// @ts-ignore
handlePaste: async (view, event: ClipboardEvent) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.clipboardData) return false;
// @ts-ignore
this.editor.commands.pasteMarkdown(content);
return true;
const text = event.clipboardData.getData('text/plain');
const html = event.clipboardData.getData('text/html');
const vscode = event.clipboardData.getData('vscode-editor-data');
// 粘贴代码
if (isInCode(view.state)) {
event.preventDefault();
view.dispatch(view.state.tr.insertText(text));
return true;
}
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
const pasteCodeLanguage = vscodeMeta?.mode;
if (pasteCodeLanguage && pasteCodeLanguage !== 'markdown') {
event.preventDefault();
view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.codeBlock.create({
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
? vscodeMeta.mode
: null,
})
)
);
view.dispatch(view.state.tr.insertText(text));
return true;
}
// 处理 markdown
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
event.preventDefault();
const paste = markdownSerializer.deserialize({
schema: view.props.state.schema,
content: normalizePastedMarkdown(text),
});
// @ts-ignore
const transaction = view.state.tr.replaceSelectionWith(paste);
view.dispatch(transaction);
return true;
}
return false;
},
clipboardTextSerializer: (slice) => {
const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content);

View File

@ -1,3 +1,12 @@
import Placeholder from '@tiptap/extension-placeholder';
import BuiltInPlaceholder from '@tiptap/extension-placeholder';
export { Placeholder };
export const Placeholder = BuiltInPlaceholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'title') {
return '请输入标题';
}
return '请输入内容';
},
showOnlyCurrent: false,
showOnlyWhenEditable: true,
});

View File

@ -0,0 +1,362 @@
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';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
goToNextSearchResult: () => void;
};
}
}
interface Result {
from: number;
to: number;
}
interface SearchOptions {
searchTerm: string;
replaceTerm: string;
results: Result[];
currentIndex: number;
searchResultClass: string;
searchResultCurrentClass: string;
caseSensitive: boolean;
disableRegex: boolean;
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const updateView = (state: EditorState<any>, dispatch: any) => dispatch(state.tr);
const regex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
return RegExp(
disableRegex ? s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') : s,
caseSensitive ? 'gu' : 'gui'
);
};
function processSearches(
doc: ProsemirrorNode,
searchTerm: RegExp,
searchResultClass: string
): { decorationsToReturn: any[]; results: Result[] } {
const decorations: Decoration[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
const results: Result[] = [];
let index = 0;
if (!searchTerm) return { decorationsToReturn: [], results: [] };
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (let i = 0; i < textNodesWithPosition.length; i += 1) {
const { text, pos } = textNodesWithPosition[i];
const matches = [...text.matchAll(searchTerm)];
for (let j = 0; j < matches.length; j += 1) {
const m = matches[j];
if (m[0] === '') break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
decorations.push(Decoration.inline(r.from, r.to, { class: searchResultClass }));
}
return {
decorationsToReturn: decorations,
results,
};
}
const replace = (replaceTerm: string, results: Result[], { state, dispatch }: any) => {
const firstResult = results[0];
if (!firstResult) return;
const { from, to } = results[0];
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
};
const rebaseNextResult = (
replaceTerm: string,
index: number,
lastOffset: number,
results: Result[]
): [number, Result[]] | null => {
const nextIndex = index + 1;
if (!results[nextIndex]) return null;
const { from: currentFrom, to: currentTo } = results[index];
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
const { from, to } = results[nextIndex];
results[nextIndex] = {
to: to - offset,
from: from - offset,
};
return [offset, results];
};
const replaceAll = (replaceTerm: string, results: Result[], { tr, dispatch }: any) => {
let offset = 0;
let ourResults = results.slice();
if (!ourResults.length) return false;
for (let i = 0; i < ourResults.length; i += 1) {
const { from, to } = ourResults[i];
tr.insertText(replaceTerm, from, to);
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, ourResults);
if (rebaseNextResultResponse) {
offset = rebaseNextResultResponse[0];
ourResults = rebaseNextResultResponse[1];
}
}
dispatch(tr);
return true;
};
const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, gotoIndex }) => {
const result = searchResults[gotoIndex];
if (result) {
let transaction = tr.setMeta('directDecoration', {
fromPos: result.from,
toPos: result.to,
attrs: { class: searchResultCurrentClass },
});
view?.dispatch(transaction);
setTimeout(() => {
const el = window.document.querySelector(`.${searchResultCurrentClass}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}
}, 0);
return true;
}
return false;
};
// eslint-disable-next-line @typescript-eslint/ban-types
export const SearchNReplace = Extension.create<SearchOptions>({
name: 'search',
defaultOptions: {
searchTerm: '',
replaceTerm: '',
results: [],
currentIndex: 0,
searchResultClass: 'search-result',
searchResultCurrentClass: 'search-result-current',
caseSensitive: false,
disableRegex: false,
},
addCommands() {
return {
setSearchTerm:
(searchTerm: string) =>
({ state, dispatch }) => {
this.options.searchTerm = searchTerm;
this.options.results = [];
this.options.currentIndex = 0;
updateView(state, dispatch);
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ state, dispatch }) => {
this.options.replaceTerm = replaceTerm;
updateView(state, dispatch);
return false;
},
replace:
() =>
({ state, dispatch }) => {
const { replaceTerm, results, currentIndex } = this.options;
const currentResult = results[currentIndex];
if (currentResult) {
replace(replaceTerm, [currentResult], { state, dispatch });
this.options.results.splice(currentIndex, 1);
} else {
replace(replaceTerm, results, { state, dispatch });
this.options.results.shift();
}
updateView(state, dispatch);
return false;
},
replaceAll:
() =>
({ state, tr, dispatch }) => {
const { replaceTerm, results } = this.options;
replaceAll(replaceTerm, results, { tr, dispatch });
this.options.results = [];
updateView(state, dispatch);
return false;
},
goToPrevSearchResult:
() =>
({ view, tr }) => {
const { currentIndex, results, searchResultCurrentClass } = this.options;
const nextIndex = (currentIndex + results.length - 1) % results.length;
this.options.currentIndex = nextIndex;
return gotoSearchResult({
view,
tr,
searchResults: results,
searchResultCurrentClass,
gotoIndex: nextIndex,
});
},
goToNextSearchResult:
() =>
({ view, tr }) => {
const { currentIndex, results, searchResultCurrentClass } = this.options;
const nextIndex = (currentIndex + 1) % results.length;
this.options.currentIndex = nextIndex;
return gotoSearchResult({
view,
tr,
searchResults: results,
searchResultCurrentClass,
gotoIndex: nextIndex,
});
},
};
},
addProseMirrorPlugins() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const extensionThis = this;
return [
new Plugin({
key: new PluginKey('search'),
state: {
init() {
return DecorationSet.empty;
},
apply(ctx) {
const { doc, docChanged } = ctx;
const {
searchTerm,
searchResultClass,
searchResultCurrentClass,
disableRegex,
caseSensitive,
} = extensionThis.options;
if (docChanged || searchTerm) {
const { decorationsToReturn, results } = processSearches(
doc,
regex(searchTerm, disableRegex, caseSensitive),
searchResultClass
);
extensionThis.options.results = results;
if (ctx.getMeta('directDecoration')) {
const { fromPos, toPos, attrs } = ctx.getMeta('directDecoration');
decorationsToReturn.push(Decoration.inline(fromPos, toPos, attrs));
} else {
if (results.length) {
decorationsToReturn[0] = Decoration.inline(results[0].from, results[0].to, {
class: searchResultCurrentClass,
});
}
}
return DecorationSet.create(doc, decorationsToReturn);
}
return DecorationSet.empty;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});

View File

@ -1,7 +1,10 @@
import { wrappingInputRule } from '@tiptap/core';
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
import { Plugin } from 'prosemirror-state';
import { findParentNodeClosestToPos } from 'prosemirror-utils';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export const TaskItem = BuiltInTaskItem.extend({
const CustomTaskItem = BuiltInTaskItem.extend({
addOptions() {
return {
nested: true,
@ -34,4 +37,49 @@ export const TaskItem = BuiltInTaskItem.extend({
},
];
},
addInputRules() {
return [
...this.parent(),
wrappingInputRule({
find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/,
type: this.type,
getAttributes: (match) => ({
checked: 'xX'.includes(match[match.length - 1]),
}),
}),
];
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// @ts-ignore
handleClick: (view, pos, event) => {
const state = view.state;
const schema = state.schema;
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
const position = state.doc.resolve(coordinates.pos);
const parentList = findParentNodeClosestToPos(position, function (node) {
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem;
});
// @ts-ignore
const isListClicked = event.target.tagName.toLowerCase() === 'li';
if (!isListClicked || !parentList || parentList.node.type !== schema.nodes.taskItem) {
return;
}
const tr = state.tr;
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
checked: !parentList.node.attrs.checked,
});
view.dispatch(tr);
},
},
}),
];
},
});
export const TaskItem = CustomTaskItem.configure({ nested: true });

View File

@ -1,32 +1,12 @@
import { mergeAttributes } from '@tiptap/core';
import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import { getMarkdownSource } from '../services/markdownSourceMap';
export const TaskList = BuiltInTaskList.extend({
addAttributes() {
return {
numeric: {
default: false,
parseHTML: (element) => element.tagName.toLowerCase() === 'ol',
},
start: {
default: 1,
parseHTML: (element) =>
element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1,
},
parens: {
default: false,
parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
},
};
},
parseHTML() {
return [
{
tag: '.task-list',
tag: 'ul.task-list',
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];

View File

@ -1,3 +1,5 @@
import TextAlign from '@tiptap/extension-text-align';
import BuiltInTextAlign from '@tiptap/extension-text-align';
export { TextAlign };
export const TextAlign = BuiltInTextAlign.configure({
types: ['heading', 'paragraph', 'image'],
});

View File

@ -2,8 +2,12 @@ import { Node, mergeAttributes } from '@tiptap/core';
export const Title = Node.create({
name: 'title',
group: 'block',
content: 'text*',
content: 'inline*',
selectable: true,
defining: true,
inline: false,
group: 'basic',
allowGapCursor: true,
addOptions() {
return {

View File

@ -1,17 +1,23 @@
import { Extension } from '@tiptap/core';
import { PluginKey, Plugin } from 'prosemirror-state';
// @ts-ignore
/**
* @param {object} args Arguments as deconstructable object
* @param {Array | object} args.types possible types
* @param {object} args.node node to check
*/
function nodeEqualsType({ types, node }) {
return (Array.isArray(types) && types.includes(node.type)) || node.type === types;
}
export interface TrailingNodeOptions {
node: string;
notAfter: string[];
}
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/tree/main/demos/src/Experiments/TrailingNode
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
export const TrailingNode = Extension.create<TrailingNodeOptions>({
export const TrailingNode = Extension.create({
name: 'trailingNode',
addOptions() {
@ -27,10 +33,14 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
.map(([, value]) => value)
.filter((node) => this.options.notAfter.includes(node.name));
const isEditable = this.editor.isEditable;
return [
new Plugin({
key: plugin,
appendTransaction: (_, __, state) => {
if (!isEditable) return;
const { doc, tr, schema } = state;
const shouldInsertNodeAtEnd = plugin.getState(state);
const endPosition = doc.content.size;
@ -44,17 +54,18 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
},
state: {
init: (_, state) => {
if (!isEditable) return false;
const lastNode = state.tr.doc.lastChild;
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
},
apply: (tr, value) => {
if (!isEditable) return value;
if (!tr.docChanged) {
return value;
}
const lastNode = tr.doc.lastChild;
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
},
},

View File

@ -6,7 +6,7 @@ import {
IconAlignRight,
IconAlignJustify,
} from '@douyinfe/semi-icons';
import { isTitleActive } from '../services/active';
import { isTitleActive } from '../services/isActive';
export const AlignMenu = ({ editor }) => {
const current = (() => {

View File

@ -1,4 +1,4 @@
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
import { Space, Button } from '@douyinfe/semi-ui';
import {
IconDelete,
IconTickCircle,
@ -6,22 +6,23 @@ import {
IconClear,
IconInfoCircle,
} from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from './components/bubble-menu';
import { Divider } from '../components/divider';
import { Banner } from '../extensions/banner';
import { deleteNode } from '../services//delete';
import { deleteNode } from '../services/deleteNode';
export const BannerBubbleMenu = ({ editor }) => {
return (
<BubbleMenu
className={'bubble-menu'}
className={'bubble-menu js-bubble-menu-banner'}
editor={editor}
pluginKey="banner-bubble-menu"
shouldShow={() => editor.isActive(Banner.name)}
matchRenderContainer={(node) => node && node.id === 'js-bannber-container'}
>
<Space>
<Tooltip content="信息" zIndex={10000}>
<Tooltip content="信息">
<Button
size="small"
type="tertiary"
@ -39,7 +40,7 @@ export const BannerBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="警告" zIndex={10000}>
<Tooltip content="警告">
<Button
onClick={() => {
editor
@ -57,7 +58,7 @@ export const BannerBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="危险" zIndex={10000}>
<Tooltip content="危险">
<Button
onClick={() => {
editor
@ -75,7 +76,7 @@ export const BannerBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="成功" zIndex={10000}>
<Tooltip content="成功">
<Button
onClick={() => {
editor
@ -95,7 +96,7 @@ export const BannerBubbleMenu = ({ editor }) => {
<Divider />
<Tooltip content="删除" zIndex={10000}>
<Tooltip content="删除" hideOnClick>
<Button
size="small"
type="tertiary"

View File

@ -6,6 +6,7 @@ import { Link } from '../extensions/link';
import { Attachment } from '../extensions/attachment';
import { Image } from '../extensions/image';
import { Banner } from '../extensions/banner';
import { HorizontalRule } from '../extensions/horizontalRule';
import { Iframe } from '../extensions/iframe';
import { Mind } from '../extensions/mind';
import { Table } from '../extensions/table';
@ -26,6 +27,7 @@ const OTHER_BUBBLE_MENU_TYPES = [
DocumentReference.name,
DocumentChildren.name,
Katex.name,
HorizontalRule.name,
];
export const BaseBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {

View File

@ -1,8 +1,10 @@
import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui';
import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons';
import { isTitleActive } from '../services/active';
import { Tooltip } from 'components/tooltip';
import { isTitleActive } from '../services/isActive';
import { Emoji } from './components/emoji';
import { Search } from './search';
export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
if (!editor) {
@ -13,7 +15,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
<>
<Emoji editor={editor} />
<Tooltip zIndex={10000} content="插入链接">
<Tooltip content="插入链接">
<Button
theme={editor.isActive('link') ? 'light' : 'borderless'}
type="tertiary"
@ -23,7 +25,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="插入引用">
<Tooltip content="插入引用">
<Button
theme={editor.isActive('blockquote') ? 'light' : 'borderless'}
type="tertiary"
@ -34,7 +36,7 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="插入分割线">
<Tooltip content="插入分割线">
<Button
theme={'borderless'}
type="tertiary"
@ -43,6 +45,8 @@ export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
disabled={isTitleActive(editor)}
/>
</Tooltip>
<Search editor={editor} />
</>
);
};

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui';
import {
IconBold,
IconItalic,
@ -7,7 +7,8 @@ import {
IconUnderline,
IconCode,
} from '@douyinfe/semi-icons';
import { isTitleActive } from '../services/active';
import { Tooltip } from 'components/tooltip';
import { isTitleActive } from '../services/isActive';
import { ColorMenu } from './color';
export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
@ -17,7 +18,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
return (
<>
<Tooltip zIndex={10000} content="粗体">
<Tooltip content="粗体">
<Button
theme={editor.isActive('bold') ? 'light' : 'borderless'}
type="tertiary"
@ -27,7 +28,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="斜体">
<Tooltip content="斜体">
<Button
theme={editor.isActive('italic') ? 'light' : 'borderless'}
type="tertiary"
@ -37,7 +38,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="下划线">
<Tooltip content="下划线">
<Button
theme={editor.isActive('underline') ? 'light' : 'borderless'}
type="tertiary"
@ -47,7 +48,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="删除线">
<Tooltip content="删除线">
<Button
theme={editor.isActive('strike') ? 'light' : 'borderless'}
type="tertiary"
@ -57,7 +58,7 @@ export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="行内代码">
<Tooltip content="行内代码">
<Button
theme={editor.isActive('code') ? 'light' : 'borderless'}
type="tertiary"

View File

@ -1,7 +1,8 @@
import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui';
import { IconFont, IconMark } from '@douyinfe/semi-icons';
import { isTitleActive } from '../services/active';
import { Tooltip } from 'components/tooltip';
import { isTitleActive } from '../services/isActive';
import { Color } from './components/color';
export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
@ -19,7 +20,7 @@ export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
}}
disabled={isTitleActive(editor)}
>
<Tooltip zIndex={10000} content="文本色">
<Tooltip content="文本色">
<Button
theme={editor.isActive('textStyle') ? 'light' : 'borderless'}
type={'tertiary'}
@ -51,7 +52,7 @@ export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => {
}}
disabled={isTitleActive(editor)}
>
<Tooltip zIndex={10000} content="背景色">
<Tooltip content="背景色">
<Button
theme={editor.isActive('textStyle') ? 'light' : 'borderless'}
type={'tertiary'}

View File

@ -1,12 +1,8 @@
.wrap {
height: 300px;
padding: 14px;
overflow: auto;
}
.sectionWrap {
}
.listWrap {
display: flex;
flex-wrap: wrap;

View File

@ -1,5 +1,6 @@
import React, { useCallback } from 'react';
import { Popover, Button, Tooltip, Typography } from '@douyinfe/semi-ui';
import { Popover, Button, Typography } from '@douyinfe/semi-ui';
import { Tooltip } from 'components/tooltip';
import { IconEmoji } from 'components/icons';
import { EXPRESSIONES, GESTURES } from './constants';
import styles from './index.module.scss';
@ -52,7 +53,7 @@ export const Emoji = ({ editor }) => {
}
>
<span>
<Tooltip zIndex={10000} content="插入表情">
<Tooltip content="插入表情">
<Button theme={'borderless'} type="tertiary" icon={<IconEmoji />} />
</Tooltip>
</span>

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { Select } from '@douyinfe/semi-ui';
import { isTitleActive } from '../../services/active';
import { isTitleActive } from '../../services/isActive';
export const FONT_SIZES = [12, 13, 14, 15, 16, 19, 22, 24, 29, 32, 40, 48];

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { Select } from '@douyinfe/semi-ui';
import { isTitleActive } from '../../services/active';
import { isTitleActive } from '../../services/isActive';
const getCurrentCaretTitle = (editor) => {
if (editor.isActive('heading', { level: 1 })) return 1;

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Space, Button, Tooltip, InputNumber, Typography } from '@douyinfe/semi-ui';
import { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui';
import {
IconAlignLeft,
IconAlignCenter,
@ -7,6 +7,7 @@ import {
IconUpload,
IconDelete,
} from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { Upload } from 'components/upload';
import { BubbleMenu } from './components/bubble-menu';
import { Divider } from '../components/divider';
@ -38,7 +39,7 @@ export const ImageBubbleMenu = ({ editor }) => {
matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'}
>
<Space>
<Tooltip content="左对齐" zIndex={10000}>
<Tooltip content="左对齐">
<Button
onClick={() => {
editor
@ -56,7 +57,7 @@ export const ImageBubbleMenu = ({ editor }) => {
size="small"
/>
</Tooltip>
<Tooltip content="居中" zIndex={10000}>
<Tooltip content="居中">
<Button
onClick={() => {
editor
@ -74,7 +75,7 @@ export const ImageBubbleMenu = ({ editor }) => {
size="small"
/>
</Tooltip>
<Tooltip content="右对齐" zIndex={10000}>
<Tooltip content="右对齐">
<Button
onClick={() => {
editor
@ -148,12 +149,12 @@ export const ImageBubbleMenu = ({ editor }) => {
}}
>
{() => (
<Tooltip content="上传图片" zIndex={10000}>
<Tooltip content="上传图片">
<Button size="small" type="tertiary" theme="borderless" icon={<IconUpload />} />
</Tooltip>
)}
</Upload>
<Tooltip content="删除" zIndex={10000}>
<Tooltip content="删除" hideOnClick>
<Button
size="small"
type="tertiary"

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Space, Button, Tooltip, Input } from '@douyinfe/semi-ui';
import { Space, Button, Input } from '@douyinfe/semi-ui';
import { IconExternalOpen, IconUnlink, IconTickCircle } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from './components/bubble-menu';
import { Link } from '../extensions/link';
@ -32,7 +33,7 @@ export const LinkBubbleMenu = ({ editor }) => {
setUrl(url);
}}
/>
<Tooltip content="设置链接" zIndex={10000}>
<Tooltip content="设置链接">
<Button
size="small"
type="tertiary"
@ -50,7 +51,7 @@ export const LinkBubbleMenu = ({ editor }) => {
}}
/>
</Tooltip>
<Tooltip content="去除链接" zIndex={10000}>
<Tooltip content="去除链接">
<Button
onClick={() => {
editor.chain().unsetLink().run();
@ -61,7 +62,7 @@ export const LinkBubbleMenu = ({ editor }) => {
size="small"
/>
</Tooltip>
<Tooltip content="访问链接" zIndex={10000}>
<Tooltip content="访问链接">
<Button
size="small"
type="tertiary"

View File

@ -1,8 +1,9 @@
import React from 'react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui';
import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { IconTask } from 'components/icons';
import { isTitleActive } from '../services/active';
import { isTitleActive } from '../services/isActive';
export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
if (!editor) {
@ -11,7 +12,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
return (
<>
<Tooltip zIndex={10000} content="无序列表">
<Tooltip content="无序列表">
<Button
theme={editor.isActive('bulletList') ? 'light' : 'borderless'}
type="tertiary"
@ -21,7 +22,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="有序列表">
<Tooltip content="有序列表">
<Button
theme={editor.isActive('orderedList') ? 'light' : 'borderless'}
type="tertiary"
@ -31,7 +32,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="任务列表">
<Tooltip content="任务列表">
<Button
theme={editor.isActive('taskList') ? 'light' : 'borderless'}
type="tertiary"
@ -41,7 +42,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="增加缩进">
<Tooltip content="增加缩进">
<Button
onClick={() => {
editor.chain().focus().indent().run();
@ -53,7 +54,7 @@ export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => {
/>
</Tooltip>
<Tooltip zIndex={10000} content="减少缩进">
<Tooltip content="减少缩进">
<Button
onClick={() => {
editor.chain().focus().outdent().run();

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Button, Tooltip, Dropdown, Popover } from '@douyinfe/semi-ui';
import { Button, Dropdown, Popover } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { Upload } from 'components/upload';
import {
IconDocument,
@ -15,7 +16,7 @@ import {
IconMath,
} from 'components/icons';
import { GridSelect } from 'components/grid-select';
import { isTitleActive } from '../services/active';
import { isTitleActive } from '../services/isActive';
import { getImageOriginSize } from '../services/image';
export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
@ -117,7 +118,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
}
>
<div>
<Tooltip content="插入" zIndex={10000}>
<Tooltip content="插入">
<Button
type="tertiary"
theme="borderless"

View File

@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react';
import { Popover, Button, Typography, Input, Space } from '@douyinfe/semi-ui';
import { Tooltip } from 'components/tooltip';
import { IconSearchReplace } from 'components/icons';
import { SearchNReplace } from '../../extensions/search';
const { Text } = Typography;
export const Search = ({ editor }) => {
const searchExtension = editor.extensionManager.extensions.find(
(ext) => ext.name === SearchNReplace.name
);
const currentIndex = searchExtension ? searchExtension.options.currentIndex : -1;
const results = searchExtension ? searchExtension.options.results : [];
const [searchValue, setSearchValue] = useState('');
const [replaceValue, setReplaceValue] = useState('');
useEffect(() => {
editor?.commands?.setSearchTerm(searchValue);
}, [searchValue]);
useEffect(() => {
editor?.commands?.setReplaceTerm(replaceValue);
}, [replaceValue]);
return (
<Popover
showArrow
zIndex={10000}
trigger="click"
position="bottomRight"
onVisibleChange={(visible) => {
if (!visible) {
setSearchValue('');
setReplaceValue('');
}
}}
content={
<div>
<div style={{ marginBottom: 12 }}>
<Text type="tertiary"></Text>
<Input
autofocus
value={searchValue}
onChange={(v) => setSearchValue(v)}
suffix={results.length ? `${currentIndex + 1}/${results.length}` : ''}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text type="tertiary"></Text>
<Input value={replaceValue} onChange={(v) => setReplaceValue(v)} />
</div>
<div>
<Space>
<Button disabled={!results.length} onClick={() => editor.commands.replaceAll()}>
</Button>
<Button disabled={!results.length} onClick={() => editor.commands.replace()}>
</Button>
<Button
disabled={!results.length}
onClick={() => editor.commands.goToPrevSearchResult()}
>
</Button>
<Button
disabled={!results.length}
onClick={() => editor.commands.goToNextSearchResult()}
>
</Button>
</Space>
</div>
</div>
}
>
<span>
<Tooltip content="查找替换">
<Button theme={'borderless'} type="tertiary" icon={<IconSearchReplace />} />
</Tooltip>
</span>
</Popover>
);
};

View File

@ -1,4 +1,4 @@
import { Space, Button, Tooltip } from '@douyinfe/semi-ui';
import { Space, Button } from '@douyinfe/semi-ui';
import {
IconAddColumnBefore,
IconAddColumnAfter,
@ -10,6 +10,7 @@ import {
IconSplitCell,
IconDeleteTable,
} from 'components/icons';
import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from './components/bubble-menu';
import { Table } from '../extensions/table';
@ -28,7 +29,7 @@ export const TableBubbleMenu = ({ editor }) => {
}
>
<Space>
<Tooltip content="向前插入一列" zIndex={10000}>
<Tooltip content="向前插入一列">
<Button
onClick={() => editor.chain().focus().addColumnBefore().run()}
icon={<IconAddColumnBefore />}
@ -38,7 +39,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="向后插入一列" zIndex={10000}>
<Tooltip content="向后插入一列">
<Button
onClick={() => editor.chain().focus().addColumnAfter().run()}
icon={<IconAddColumnAfter />}
@ -47,7 +48,7 @@ export const TableBubbleMenu = ({ editor }) => {
size="small"
/>
</Tooltip>
<Tooltip content="删除当前列" zIndex={10000}>
<Tooltip content="删除当前列">
<Button
onClick={() => editor.chain().focus().deleteColumn().run()}
icon={<IconDeleteColumn />}
@ -57,7 +58,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="向前插入一行" zIndex={10000}>
<Tooltip content="向前插入一行">
<Button
onClick={() => editor.chain().focus().addRowBefore().run()}
icon={<IconAddRowBefore />}
@ -67,7 +68,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="向后插入一行" zIndex={10000}>
<Tooltip content="向后插入一行">
<Button
onClick={() => editor.chain().focus().addRowAfter().run()}
icon={<IconAddRowAfter />}
@ -77,7 +78,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="删除当前行" zIndex={10000}>
<Tooltip content="删除当前行">
<Button
onClick={() => editor.chain().focus().deleteRow().run()}
icon={<IconDeleteRow />}
@ -87,7 +88,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="合并单元格" zIndex={10000}>
<Tooltip content="合并单元格">
<Button
size="small"
type="tertiary"
@ -97,7 +98,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="分离单元格" zIndex={10000}>
<Tooltip content="分离单元格">
<Button
size="small"
type="tertiary"
@ -107,7 +108,7 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Tooltip content="删除表格" zIndex={10000}>
<Tooltip content="删除表格" hideOnClick>
<Button
size="small"
type="tertiary"

View File

@ -0,0 +1,21 @@
import { InputRule, wrappingInputRule } from '@tiptap/core';
/**
* Wrapping input handler that will append the content of the last match
*
* @param {RegExp} find find param for the wrapping input rule
* @param {object} type Node Type object
* @param {*} getAttributes handler to get the attributes
*/
export function listInputRule(find, type, getAttributes = null) {
const handler = ({ state, range, match }) => {
const wrap = wrappingInputRule({ find, type, getAttributes });
// @ts-ignore
wrap.handler({ state, range, match });
// Insert the first character after bullet if there is one
if (match.length >= 3) {
state.tr.insertText(match[2]);
}
};
return new InputRule({ find, handler });
}

View File

@ -0,0 +1,26 @@
import markdownit from 'markdown-it';
import sub from 'markdown-it-sub';
import sup from 'markdown-it-sup';
import footnote from 'markdown-it-footnote';
import anchor from 'markdown-it-anchor';
import tasklist from 'markdown-it-task-lists';
import emoji from 'markdown-it-emoji';
import katex from '@traptitech/markdown-it-katex';
import splitMixedLists from './markedownSplitMixedList';
import markdownUnderline from './markdownUnderline';
import markdownBanner from './markdownBanner';
export const markdown = markdownit('commonmark', { html: false, breaks: false })
.enable('strikethrough')
.use(sub)
.use(sup)
.use(footnote)
.use(anchor)
.use(tasklist, { enable: true })
.use(splitMixedLists)
.use(markdownUnderline)
.use(markdownBanner)
.use(emoji)
.use(katex);
export * from './serializer';

View File

@ -0,0 +1,29 @@
import container from 'markdown-it-container';
export const typesAvailable = ['info', 'warning', 'danger', 'success'];
const buildRender = (type) => (tokens, idx, options, env, slf) => {
const tag = tokens[idx];
// add attributes to the opening tag
if (tag.nesting === 1) {
tag.attrSet('data-banner', type);
tag.attrJoin('class', `banner banner-${type}`);
}
return slf.renderToken(tokens, idx, options, env, slf);
};
/**
* @param {object} md Markdown object
*/
export default function markdownBanner(md) {
// create a custom container to each callout type
typesAvailable.forEach((type) => {
md.use(container, type, {
render: buildRender(type),
});
});
return md;
}

View File

@ -0,0 +1,22 @@
export default function markdownUnderlines(md) {
md.inline.ruler2.after('emphasis', 'underline', (state) => {
const tokens = state.tokens;
for (let i = tokens.length - 1; i > 0; i--) {
const token = tokens[i];
if (token.markup === '__') {
if (token.type === 'strong_open') {
tokens[i].tag = 'u';
tokens[i].type = 'u_open';
}
if (token.type === 'strong_close') {
tokens[i].tag = 'u';
tokens[i].type = 'u_close';
}
}
}
return false;
});
}

View File

@ -0,0 +1,64 @@
/**
* @param {object} md Markdown object
*/
export default function splitMixedLists(md) {
md.core.ruler.after('github-task-lists', 'split-mixed-task-lists', (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.attrGet('class') !== 'contains-task-list') {
continue;
}
const firstChild = tokens[i + 1];
const startsWithTask = firstChild.attrGet('class') === 'task-list-item';
if (!startsWithTask) {
token.attrs.splice(token.attrIndex('class'));
if (token.attrs.length === 0) {
token.attrs = null;
}
}
const splitBefore = findChildOf(tokens, i, (child) => {
return child.nesting === 1 && child.attrGet('class') !== firstChild.attrGet('class');
});
if (splitBefore > i) {
splitListAt(tokens, splitBefore, state.Token);
}
}
return false;
});
}
/**
* @param {Array} tokens - all the tokens in the doc
* @param {number} index - index into the tokens array where to split
* @param {object} TokenConstructor - constructor provided by Markdown-it
*/
function splitListAt(tokens, index, TokenConstructor) {
const closeList = new TokenConstructor('bullet_list_close', 'ul', -1);
closeList.block = true;
const openList = new TokenConstructor('bullet_list_open', 'ul', 1);
openList.attrSet('class', 'contains-task-list');
openList.block = true;
tokens.splice(index, 0, closeList, openList);
}
/**
* @param {Array} tokens - all the tokens in the doc
* @param {number} parentIndex - index of the parent in the tokens array
* @param {Function} predicate - test function returned child needs to pass
*/
function findChildOf(tokens, parentIndex, predicate) {
const searchLevel = tokens[parentIndex].level + 1;
for (let i = parentIndex + 1; i < tokens.length; i++) {
const token = tokens[i];
if (token.level < searchLevel) {
return -1;
}
if (token.level === searchLevel && predicate(tokens[i])) {
return i;
}
}
return -1;
}

View File

@ -4,41 +4,41 @@ import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
} from 'prosemirror-markdown';
import { marked } from './marked';
import { Attachment } from '../extensions/attachment';
import { Banner } from '../extensions/banner';
import { Blockquote } from '../extensions/blockquote';
import { Bold } from '../extensions/bold';
import { BulletList } from '../extensions/bulletList';
import { Code } from '../extensions/code';
import { CodeBlock } from '../extensions/codeBlock';
import { DocumentChildren } from '../extensions/documentChildren';
import { DocumentReference } from '../extensions/documentReference';
import { FootnoteDefinition } from '../extensions/footnoteDefinition';
import { FootnoteReference } from '../extensions/footnoteReference';
import { FootnotesSection } from '../extensions/footnotesSection';
import { HardBreak } from '../extensions/hardBreak';
import { Heading } from '../extensions/heading';
import { HorizontalRule } from '../extensions/horizontalRule';
import { HTMLMarks } from '../extensions/htmlMarks';
import { Iframe } from '../extensions/iframe';
import { Image } from '../extensions/image';
import { Italic } from '../extensions/italic';
import { Katex } from '../extensions/katex';
import { Link } from '../extensions/link';
import { ListItem } from '../extensions/listItem';
import { Mind } from '../extensions/mind';
import { OrderedList } from '../extensions/orderedList';
import { Paragraph } from '../extensions/paragraph';
import { Strike } from '../extensions/strike';
import { Table } from '../extensions/table';
import { TableCell } from '../extensions/tableCell';
import { TableHeader } from '../extensions/tableHeader';
import { TableRow } from '../extensions/tableRow';
import { Text } from '../extensions/text';
import { TaskItem } from '../extensions/taskItem';
import { TaskList } from '../extensions/taskList';
import { Title } from '../extensions/title';
import { markdown } from '.';
import { Attachment } from '../../extensions/attachment';
import { Banner } from '../../extensions/banner';
import { Blockquote } from '../../extensions/blockquote';
import { Bold } from '../../extensions/bold';
import { BulletList } from '../../extensions/bulletList';
import { Code } from '../../extensions/code';
import { CodeBlock } from '../../extensions/codeBlock';
import { DocumentChildren } from '../../extensions/documentChildren';
import { DocumentReference } from '../../extensions/documentReference';
import { FootnoteDefinition } from '../../extensions/footnoteDefinition';
import { FootnoteReference } from '../../extensions/footnoteReference';
import { FootnotesSection } from '../../extensions/footnotesSection';
import { HardBreak } from '../../extensions/hardBreak';
import { Heading } from '../../extensions/heading';
import { HorizontalRule } from '../../extensions/horizontalRule';
import { HTMLMarks } from '../../extensions/htmlMarks';
import { Iframe } from '../../extensions/iframe';
import { Image } from '../../extensions/image';
import { Italic } from '../../extensions/italic';
import { Katex } from '../../extensions/katex';
import { Link } from '../../extensions/link';
import { ListItem } from '../../extensions/listItem';
import { Mind } from '../../extensions/mind';
import { OrderedList } from '../../extensions/orderedList';
import { Paragraph } from '../../extensions/paragraph';
import { Strike } from '../../extensions/strike';
import { Table } from '../../extensions/table';
import { TableCell } from '../../extensions/tableCell';
import { TableHeader } from '../../extensions/tableHeader';
import { TableRow } from '../../extensions/tableRow';
import { Text } from '../../extensions/text';
import { TaskItem } from '../../extensions/taskItem';
import { TaskList } from '../../extensions/taskList';
import { Title } from '../../extensions/title';
import {
isPlainURL,
renderHardBreak,
@ -96,8 +96,11 @@ const defaultSerializerConfig = {
state.closeBlock(node);
},
[Banner.name]: (state, node) => {
state.write(`:::${node.attrs.type || 'info'}\n`);
state.ensureNewLine();
state.write(`banner$`);
state.renderContent(node);
state.ensureNewLine();
state.write(':::');
state.closeBlock(node);
},
[Blockquote.name]: (state, node) => {
@ -151,8 +154,10 @@ const defaultSerializerConfig = {
},
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
[Mind.name]: (state, node) => {
state.write(`$mind\n`);
state.ensureNewLine();
state.renderContent(node);
state.ensureNewLine();
state.write(`mind$`);
state.closeBlock(node);
},
[OrderedList.name]: renderOrderedList,
@ -166,16 +171,22 @@ const defaultSerializerConfig = {
state.renderContent(node);
},
[TaskList.name]: (state, node) => {
if (node.attrs.numeric) renderOrderedList(state, node);
else defaultMarkdownSerializer.nodes.bullet_list(state, node);
state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' ');
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Title.name]: renderHTMLNode('h1', true, true, { class: 'title' }),
[Title.name]: (state, node) => {
if (!node.textContent) return;
state.write(`# `);
state.text(node.textContent, false);
state.ensureNewLine();
state.closeBlock(node);
},
},
};
const renderMarkdown = (rawMarkdown) => {
return sanitize(marked.render(rawMarkdown), {});
return sanitize(markdown.render(rawMarkdown), {});
};
const createMarkdownSerializer = () => ({

View File

@ -1,4 +1,3 @@
// @ts-ignore
const uniq = (arr: string[]) => [...new Set(arr)];
function isString(value) {

View File

@ -1,15 +0,0 @@
import markdownit from 'markdown-it';
import sub from 'markdown-it-sub';
import sup from 'markdown-it-sup';
import footnote from 'markdown-it-footnote';
import anchor from 'markdown-it-anchor';
import tasklist from 'markdown-it-task-lists';
import katex from '@traptitech/markdown-it-katex';
export const marked = markdownit()
.use(sub)
.use(sup)
.use(footnote)
.use(anchor)
.use(tasklist)
.use(katex);

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Tooltip as SemiTooltip } from '@douyinfe/semi-ui';
import { useToggle } from 'hooks/useToggle';
let id = 0;
interface IProps {
content: React.ReactNode;
hideOnClick?: boolean;
}
export const Tooltip: React.FC<IProps> = ({ content, hideOnClick = false, children }) => {
const [visible, toggleVisible] = useToggle(false);
return (
<SemiTooltip visible={visible} content={content} zIndex={10000} trigger={'custom'}>
<span
onMouseEnter={() => {
toggleVisible(true);
}}
onMouseLeave={() => {
toggleVisible(false);
}}
onClick={() => {
hideOnClick && toggleVisible(false);
}}
>
{children}
</span>
</SemiTooltip>
);
};

View File

@ -198,3 +198,18 @@ a {
.react-resizable-handle-s {
cursor: ns-resize;
}
.react-tooltip {
font-size: 14px;
line-height: 20px;
background-color: rgba(var(--semi-grey-7), 1) !important;
color: var(--semi-color-bg-0) !important;
border-radius: var(--semi-border-radius-medium) !important;
padding: 6px 8px !important;
z-index: 10000 !important;
white-space: nowrap;
&::after {
border-top-color: rgba(var(--semi-grey-7), 1) !important;
}
}

View File

@ -41,7 +41,7 @@
.is-empty::before {
content: attr(data-placeholder);
float: left;
color: #ced4da;
color: #aaa;
pointer-events: none;
height: 0;
}
@ -99,6 +99,7 @@
font-weight: bold;
color: var(--semi-color-text-0);
margin: 10px 0 22px;
border-bottom: 1px solid var(--semi-color-border);
}
h1 {
@ -275,6 +276,14 @@
width: 4px;
}
}
.search-result {
background: rgb(255, 217, 0);
}
.search-result-current {
background: rgb(255, 0, 0);
}
}
.resize-cursor {

View File

@ -85,6 +85,7 @@ importers:
'@tiptap/extension-text-style': ^2.0.0-beta.23
'@tiptap/extension-underline': ^2.0.0-beta.23
'@tiptap/react': ^2.0.0-beta.107
'@tiptap/suggestion': ^2.0.0-beta.90
'@traptitech/markdown-it-katex': ^3.5.0
'@types/node': 17.0.13
'@types/react': 17.0.38
@ -98,6 +99,8 @@ importers:
lowlight: ^2.5.0
markdown-it: ^12.3.2
markdown-it-anchor: ^8.4.1
markdown-it-container: ^3.0.0
markdown-it-emoji: ^2.0.0
markdown-it-footnote: ^3.0.3
markdown-it-sub: ^1.0.0
markdown-it-sup: ^1.0.0
@ -105,11 +108,13 @@ importers:
marked: ^4.0.12
next: 12.0.10
prosemirror-markdown: ^1.7.0
prosemirror-utils: ^0.9.6
prosemirror-view: ^1.23.6
react: 17.0.2
react-dom: 17.0.2
react-helmet: ^6.1.0
react-split-pane: ^0.1.92
scroll-into-view-if-needed: ^2.2.29
swr: ^1.2.0
tippy.js: ^6.3.7
tsconfig-paths-webpack-plugin: ^3.5.2
@ -157,6 +162,7 @@ importers:
'@tiptap/extension-text-style': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-underline': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171
'@tiptap/react': 2.0.0-beta.107_a3fcdb91535fe17b69dfabaa94f3bb3d
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
'@traptitech/markdown-it-katex': 3.5.0
axios: 0.25.0
classnames: 2.3.1
@ -168,6 +174,8 @@ importers:
lowlight: 2.5.0
markdown-it: 12.3.2
markdown-it-anchor: 8.4.1_markdown-it@12.3.2
markdown-it-container: 3.0.0
markdown-it-emoji: 2.0.0
markdown-it-footnote: 3.0.3
markdown-it-sub: 1.0.0
markdown-it-sup: 1.0.0
@ -175,11 +183,13 @@ 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-view: 1.23.6
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-helmet: 6.1.0_react@17.0.2
react-split-pane: 0.1.92_react-dom@17.0.2+react@17.0.2
scroll-into-view-if-needed: 2.2.29
swr: 1.2.0_react@17.0.2
tippy.js: 6.3.7
devDependencies:
@ -792,7 +802,7 @@ packages:
date-fns-tz: 1.2.2_date-fns@2.28.0
lodash: 4.17.21
memoize-one: 5.2.1
scroll-into-view-if-needed: 2.2.28
scroll-into-view-if-needed: 2.2.29
dev: false
/@douyinfe/semi-icons/2.3.1_react@17.0.2:
@ -860,7 +870,7 @@ packages:
react-sortable-hoc: 1.11.0_react-dom@17.0.2+react@17.0.2
react-window: 1.8.6_react-dom@17.0.2+react@17.0.2
resize-observer-polyfill: 1.5.1
scroll-into-view-if-needed: 2.2.28
scroll-into-view-if-needed: 2.2.29
utility-types: 3.10.0
dev: false
@ -1914,6 +1924,17 @@ packages:
react-dom: 17.0.2_react@17.0.2
dev: false
/@tiptap/suggestion/2.0.0-beta.90_@tiptap+core@2.0.0-beta.171:
resolution: {integrity: sha512-L5PPYRatY/75uJJRQx2o/Ce+gzcOkmd81TwLjio9sADV3bRf4DO4WYcQy0AtGe6uNSz78DTL0SUVw4204VjoBw==}
peerDependencies:
'@tiptap/core': ^2.0.0-beta.1
dependencies:
'@tiptap/core': 2.0.0-beta.171
prosemirror-model: 1.16.1
prosemirror-state: 1.3.4
prosemirror-view: 1.23.6
dev: false
/@tootallnate/once/1.1.2:
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
@ -5958,6 +5979,14 @@ packages:
markdown-it: 12.3.2
dev: false
/markdown-it-container/3.0.0:
resolution: {integrity: sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==}
dev: false
/markdown-it-emoji/2.0.0:
resolution: {integrity: sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==}
dev: false
/markdown-it-footnote/3.0.3:
resolution: {integrity: sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w==}
dev: false
@ -6811,6 +6840,14 @@ packages:
prosemirror-model: 1.16.1
dev: false
/prosemirror-utils/0.9.6:
resolution: {integrity: sha512-UC+j9hQQ1POYfMc5p7UFxBTptRiGPR7Kkmbl3jVvU8VgQbkI89tR/GK+3QYC8n+VvBZrtAoCrJItNhWSxX3slA==}
peerDependencies:
prosemirror-model: ^1.0.0
prosemirror-state: ^1.0.1
prosemirror-tables: ^0.9.1
dev: false
/prosemirror-view/1.23.6:
resolution: {integrity: sha512-B4DAzriNpI/AVoW0Lu6SVfX00jZZQxOVwdBQEjWlRbCdT9V0pvk4GQJ3JTFaib+b6BcPdRZ3MjWXz2xvV1rblA==}
dependencies:
@ -7274,8 +7311,8 @@ packages:
ajv: 6.12.6
ajv-keywords: 3.5.2_ajv@6.12.6
/scroll-into-view-if-needed/2.2.28:
resolution: {integrity: sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w==}
/scroll-into-view-if-needed/2.2.29:
resolution: {integrity: sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==}
dependencies:
compute-scroll-into-view: 1.0.17
dev: false