feat: improve tiptap

This commit is contained in:
fantasticit 2022-03-21 16:46:27 +08:00
parent ea34e23422
commit f68303720f
46 changed files with 955 additions and 764 deletions

View File

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

View File

@ -6,6 +6,7 @@ import { ILoginUser, IAuthority } from '@think/domains';
import { useToggle } from 'hooks/useToggle';
import {
DEFAULT_EXTENSION,
Document,
DocumentWithTitle,
getCollaborationExtension,
getCollaborationCursorExtension,
@ -44,6 +45,11 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
},
});
}, [documentId, user.token]);
const noTitleEditor = useEditor({
extensions: [...DEFAULT_EXTENSION, Document],
});
const editor = useEditor({
editable: authority && authority.editable,
extensions: [
@ -52,6 +58,10 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
getCollaborationExtension(provider),
getCollaborationCursorExtension(provider, user),
],
editorProps: {
// @ts-ignore
noTitleEditor,
},
});
const [loading, toggleLoading] = useToggle(true);

View File

@ -29,11 +29,11 @@ import { Italic } from './extensions/italic';
import { Katex } from './extensions/katex';
import { Link } from './extensions/link';
import { ListItem } from './extensions/listItem';
import { Loading } from './extensions/loading';
import { Mind } from './extensions/mind';
import { OrderedList } from './extensions/orderedList';
import { Paragraph } from './extensions/paragraph';
import { PasteFile } from './extensions/pasteFile';
import { PasteMarkdown } from './extensions/pasteMarkdown';
import { Paste } from './extensions/paste';
import { Placeholder } from './extensions/placeholder';
import { SearchNReplace } from './extensions/search';
import { Status } from './extensions/status';
@ -83,11 +83,11 @@ export const BaseKit = [
Katex,
Link,
ListItem,
Loading,
Mind,
OrderedList,
Paragraph,
PasteFile,
PasteMarkdown,
Paste,
Placeholder,
SearchNReplace,
Status,

View File

@ -1,27 +1,84 @@
import { useEffect, useRef } from 'react';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Button } from '@douyinfe/semi-ui';
import { Button, Typography, Spin } from '@douyinfe/semi-ui';
import { IconDownload } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { useToggle } from 'hooks/useToggle';
import { download } from '../../services/download';
import { uploadFile } from 'services/file';
import { normalizeFileSize, extractFileExtension, extractFilename } from '../../services/file';
import styles from './index.module.scss';
export const AttachmentWrapper = ({ node }) => {
const { name, url } = node.attrs;
const { Text } = Typography;
export const AttachmentWrapper = ({ node, updateAttributes }) => {
const $upload = useRef();
const { autoTrigger, fileName, fileSize, fileExt, fileType, url, error } = node.attrs;
const [loading, toggleLoading] = useToggle(false);
const selectFile = () => {
// @ts-ignore
$upload.current.click();
};
const handleFile = async (e) => {
const file = e.target.files && e.target.files[0];
const fileInfo = {
fileName: extractFilename(file.name),
fileSize: file.size,
fileType: file.type,
fileExt: extractFileExtension(file.name),
};
toggleLoading(true);
try {
const url = await uploadFile(file);
updateAttributes({ ...fileInfo, url });
toggleLoading(false);
} catch (error) {
updateAttributes({ error: '上传失败:' + (error && error.message) || '未知错误' });
toggleLoading(false);
}
};
useEffect(() => {
if (!url && !autoTrigger) {
selectFile();
updateAttributes({ autoTrigger: true });
}
}, [url, autoTrigger]);
return (
<NodeViewWrapper as="div">
<div className={styles.wrap}>
<span>{name}</span>
<span>
<Tooltip content="下载">
<Button
theme={'borderless'}
type="tertiary"
icon={<IconDownload />}
onClick={() => download(url, name)}
/>
</Tooltip>
</span>
{!url ? (
error ? (
<Text>{error}</Text>
) : (
<Spin spinning={loading}>
<Text onClick={selectFile} style={{ cursor: 'pointer' }}>
{loading ? '正在上传中' : '请选择文件'}
</Text>
<input ref={$upload} type="file" hidden onChange={handleFile} />
</Spin>
)
) : (
<>
<span>
{fileName}.{fileExt}
<Text type="tertiary"> ({normalizeFileSize(fileSize)})</Text>
</span>
<span>
<Tooltip content="下载">
<Button
theme={'borderless'}
type="tertiary"
icon={<IconDownload />}
onClick={() => download(url, name)}
/>
</Tooltip>
</span>
</>
)}
</div>
<NodeViewContent></NodeViewContent>
</NodeViewWrapper>

View File

@ -0,0 +1,10 @@
.wrap {
display: flex;
justify-content: space-between;
align-items: center;
margin: 10px 0;
padding: 8px 16px;
border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius);
cursor: pointer;
}

View File

@ -1,25 +1,89 @@
import { NodeViewWrapper } from '@tiptap/react';
import { Resizeable } from 'components/resizeable';
import { useEffect, useRef } from 'react';
import { Typography, Spin } from '@douyinfe/semi-ui';
import { useToggle } from 'hooks/useToggle';
import { uploadFile } from 'services/file';
import { extractFileExtension, extractFilename } from '../../services/file';
import styles from './index.module.scss';
const { Text } = Typography;
export const ImageWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable;
const { src, alt, title, width, height, textAlign } = node.attrs;
const { autoTrigger, error, src, alt, title, width, height, textAlign } = node.attrs;
const $upload = useRef();
const [loading, toggleLoading] = useToggle(false);
const onResize = (size) => {
updateAttributes({ height: size.height, width: size.width });
};
const content = src && <img src={src} alt={alt} width={width} height={height} />;
const selectFile = () => {
// @ts-ignore
$upload.current.click();
};
const handleFile = async (e) => {
const file = e.target.files && e.target.files[0];
const fileInfo = {
fileName: extractFilename(file.name),
fileSize: file.size,
fileType: file.type,
fileExt: extractFileExtension(file.name),
};
toggleLoading(true);
try {
const src = await uploadFile(file);
updateAttributes({ ...fileInfo, src });
toggleLoading(false);
} catch (error) {
updateAttributes({ error: '上传失败:' + (error && error.message) || '未知错误' });
toggleLoading(false);
}
};
useEffect(() => {
if (!src && !autoTrigger) {
selectFile();
updateAttributes({ autoTrigger: true });
}
}, [src, autoTrigger]);
const content = (() => {
if (error) {
return <Text>{error}</Text>;
}
if (!src) {
return (
<div className={styles.wrap}>
<Spin spinning={loading}>
<Text onClick={selectFile} style={{ cursor: 'pointer' }}>
{loading ? '正在上传中' : '请选择图片'}
</Text>
<input ref={$upload} accept="image/*" type="file" hidden onChange={handleFile} />
</Spin>
</div>
);
}
const img = <img src={src} alt={alt} width={width} height={height} />;
if (isEditable) {
return (
<Resizeable width={width} height={height} onChange={onResize}>
{img}
</Resizeable>
);
}
return <div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{img}</div>;
})();
return (
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
{isEditable ? (
<Resizeable width={width} height={height} onChange={onResize}>
{content}
</Resizeable>
) : (
<div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{content}</div>
)}
{content}
</NodeViewWrapper>
);
};

View File

@ -0,0 +1,26 @@
import { NodeViewWrapper } from '@tiptap/react';
import { Spin } from '@douyinfe/semi-ui';
export const LoadingWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable;
const { text } = node.attrs;
if (!isEditable) return <NodeViewWrapper />;
return (
<NodeViewWrapper as="div">
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '1em',
alignItems: 'center',
whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
<Spin tip={text ? `正在上传${text}中...` : ''} />
</div>
</NodeViewWrapper>
);
};

View File

@ -12,6 +12,7 @@ interface IProps {
export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
const $container = useRef<HTMLDivElement>();
const $image = useRef<HTMLInputElement>();
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
@ -34,6 +35,10 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
selectItem(selectedIndex);
};
const handleSelectImage = function () {
console.log('image', this.files);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useEffect(() => {

View File

@ -2,6 +2,14 @@ import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { AttachmentWrapper } from '../components/attachment';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
attachment: {
setAttachment: (attrs?: unknown) => ReturnType;
};
}
}
export const Attachment = Node.create({
name: 'attachment',
group: 'block',
@ -26,19 +34,34 @@ export const Attachment = Node.create({
addAttributes() {
return {
name: {
fileName: {
default: null,
},
fileSize: {
default: null,
},
fileType: {
default: null,
},
fileExt: {
default: null,
},
url: {
default: null,
},
autoTrigger: {
default: false,
},
error: {
default: null,
},
};
},
// @ts-ignore
addCommands() {
return {
setAttachment:
(attrs) =>
(attrs = {}) =>
({ chain }) => {
return chain().insertContent({ type: this.name, attrs }).run();
},

View File

@ -4,9 +4,9 @@ import { BannerWrapper } from '../components/banner';
import { typesAvailable } from '../services/markdown/markdownBanner';
declare module '@tiptap/core' {
interface Commands {
interface Commands<ReturnType> {
banner: {
setBanner: () => Command;
setBanner: (attrs) => ReturnType;
};
}
}

View File

@ -1,11 +1,11 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { DocumentChildrenWrapper } from '../components/documentChildren';
declare module '@tiptap/core' {
interface Commands {
interface Commands<ReturnType> {
documentChildren: {
setDocumentChildren: () => Command;
setDocumentChildren: () => ReturnType;
};
}
}
@ -35,10 +35,7 @@ export const DocumentChildren = Node.create({
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
},
// @ts-ignore

View File

@ -1,11 +1,11 @@
import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { DocumentReferenceWrapper } from '../components/documentReference';
declare module '@tiptap/core' {
interface Commands {
interface Commands<ReturnType> {
documentReference: {
setDocumentReference: () => Command;
setDocumentReference: () => ReturnType;
};
}
}
@ -38,10 +38,7 @@ export const DocumentReference = Node.create({
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
},
// @ts-ignore

View File

@ -7,6 +7,14 @@ import tippy from 'tippy.js';
import { EmojiList } from '../components/emojiList';
import { emojiSearch, emojisToName } from '../components/emojiList/emojis';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
emoji: {
setEmoji: (emoji: { name: string; emoji: string }) => ReturnType;
};
}
}
export const EmojiPluginKey = new PluginKey('emoji');
export { emojisToName };
export const Emoji = Node.create({
@ -30,10 +38,9 @@ export const Emoji = Node.create({
};
},
// @ts-ignore
addCommands() {
return {
emoji:
setEmoji:
(emojiObject) =>
({ commands }) => {
return commands.insertContent(emojiObject.emoji + ' ');
@ -56,9 +63,7 @@ export const Emoji = Node.create({
decorations: (state) => {
if (!editor.isEditable) return;
const parent = findParentNode((node) => node.type.name === 'paragraph')(
state.selection
);
const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
if (!parent) {
return;
}

View File

@ -4,248 +4,11 @@ import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import Suggestion from '@tiptap/suggestion';
import tippy from 'tippy.js';
import { Space } from '@douyinfe/semi-ui';
import { IconList, IconOrderedList } from '@douyinfe/semi-icons';
import {
IconLink,
IconQuote,
IconHorizontalRule,
IconTask,
IconDocument,
IconMind,
IconTable,
IconImage,
IconCodeBlock,
IconStatus,
IconInfo,
IconAttachment,
IconMath,
} from 'components/icons';
import { Upload } from 'components/upload';
import { MenuList } from '../components/menuList';
import { getImageOriginSize } from '../services/image';
import { EVOKE_MENU_ITEMS } from '../menus/evokeMenu';
export const EvokeMenuPluginKey = new PluginKey('evokeMenu');
const COMMANDS = [
{
key: '标题1',
label: '标题1',
command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
key: '标题1',
label: '标题2',
command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
key: '标题1',
label: '标题3',
command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
key: '标题1',
label: '标题4',
command: (editor) => editor.chain().focus().toggleHeading({ level: 4 }).run(),
},
{
key: '标题1',
label: '标题5',
command: (editor) => editor.chain().focus().toggleHeading({ level: 5 }).run(),
},
{
key: '标题1',
label: '标题6',
command: (editor) => editor.chain().focus().toggleHeading({ level: 6 }).run(),
},
{
key: '无序列表',
label: (
<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',
@ -261,7 +24,7 @@ export const EvokeMenu = Node.create({
const tr = state.tr.deleteRange($from.start(), $from.pos);
dispatch(tr);
props?.command(editor);
editor.view.focus();
editor?.view?.focus();
},
},
};
@ -282,9 +45,7 @@ export const EvokeMenu = Node.create({
decorations: (state) => {
if (!editor.isEditable) return;
const parent = findParentNode((node) => node.type.name === 'paragraph')(
state.selection
);
const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
if (!parent) {
return;
}
@ -324,7 +85,7 @@ export const EvokeMenu = Node.create({
}).configure({
suggestion: {
items: ({ query }) => {
return COMMANDS.filter((command) => command.key.startsWith(query));
return EVOKE_MENU_ITEMS.filter((command) => command.key.startsWith(query));
},
render: () => {
let component;

View File

@ -2,6 +2,14 @@ import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { IframeWrapper } from '../components/iframe';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
setIframe: (attrs) => ReturnType;
};
}
}
export const Iframe = Node.create({
name: 'external-iframe',
content: '',
@ -47,14 +55,15 @@ export const Iframe = Node.create({
// @ts-ignore
addCommands() {
return {
insertIframe:
setIframe:
(options) =>
({ tr, commands, chain, editor }) => {
// @ts-ignore
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
const { url } = options || {};
const { url } = options || { url: '' };
const { selection } = editor.state;
const pos = selection.$head;

View File

@ -2,8 +2,15 @@ import { Image as BuiltInImage } from '@tiptap/extension-image';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { ImageWrapper } from '../components/image';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
const resolveImageEl = (element) => (element.nodeName === 'IMG' ? element : element.querySelector('img'));
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iamge: {
setEmptyImage: () => ReturnType;
};
}
}
export const Image = BuiltInImage.extend({
addOptions() {
@ -19,7 +26,6 @@ export const Image = BuiltInImage.extend({
default: null,
parseHTML: (element) => {
const img = resolveImageEl(element);
return img.dataset.src || img.getAttribute('src');
},
},
@ -40,6 +46,22 @@ export const Image = BuiltInImage.extend({
height: {
default: 'auto',
},
autoTrigger: {
default: false,
},
error: {
default: null,
},
};
},
addCommands() {
return {
...this.parent?.(),
setEmptyImage:
(attrs = {}) =>
({ chain }) => {
return chain().insertContent({ type: this.name, attrs }).run();
},
};
},
addNodeView() {

View File

@ -1,11 +1,11 @@
import { Node, Command, mergeAttributes, nodeInputRule } from '@tiptap/core';
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { KatexWrapper } from '../components/katex';
declare module '@tiptap/core' {
interface Commands {
interface Commands<ReturnType> {
katex: {
setKatex: () => Command;
setKatex: () => ReturnType;
};
}
}
@ -33,10 +33,7 @@ export const Katex = Node.create({
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
},
// @ts-ignore

View File

@ -0,0 +1,22 @@
import { Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { LoadingWrapper } from '../components/loading';
export const Loading = Node.create({
name: 'loading',
inline: true,
group: 'inline',
atom: true,
addAttributes() {
return {
text: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer(LoadingWrapper);
},
});

View File

@ -12,6 +12,14 @@ const DEFAULT_MIND_DATA = {
data: { id: 'root', topic: '中心节点', children: [] },
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mind: {
setMind: (attrs?: unknown) => ReturnType;
};
}
}
export const Mind = Node.create({
name: 'jsmind',
content: '',
@ -57,9 +65,10 @@ export const Mind = Node.create({
// @ts-ignore
addCommands() {
return {
insertMind:
setMind:
(options) =>
({ tr, commands, chain, editor }) => {
// @ts-ignore
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}

View File

@ -1,90 +1,37 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
// @ts-ignore
import { lowlight } from 'lowlight';
import { Plugin, PluginKey } from 'prosemirror-state';
import { markdownSerializer } from '../services/markdown';
import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
import { handleFileEvent } from '../services/upload';
import { isInCode, LANGUAGES } from '../services/code';
import { isMarkdown, normalizePastedMarkdown } from '../services/markdown/helpers';
const isMarkActive =
(type) =>
(state: EditorState): boolean => {
if (!type) {
return false;
}
const { from, $from, to, empty } = state.selection;
return empty
? type.isInSet(state.storedMarks || $from.marks())
: state.doc.rangeHasMark(from, to, type);
};
export default function isInCode(state: EditorState): boolean {
if (state.schema.nodes.codeBlock) {
const $head = state.selection.$head;
for (let d = $head.depth; d > 0; d--) {
if ($head.node(d).type === state.schema.nodes.codeBlock) {
return true;
}
}
}
return isMarkActive(state.schema.marks.code)(state);
}
const LANGUAGES = lowlight.listLanguages().reduce((a, language) => {
a[language] = language;
return a;
}, {});
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
};
function isMarkdown(text: string): boolean {
// code-ish
const fences = text.match(/^```/gm);
if (fences && fences.length > 1) return true;
// link-ish
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
// heading-ish
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
// list-ish
const listItems = text.match(/^[\d-*].?\s\S+/gm);
if (listItems && listItems.length > 1) return true;
return false;
}
function normalizePastedMarkdown(text: string): string {
const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim;
while (text.match(CHECKBOX_REGEX)) {
text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`);
}
return text;
}
export const PasteMarkdown = Extension.create({
name: 'pasteMarkdown',
export const Paste = Extension.create({
name: 'paste',
priority: EXTENSION_PRIORITY_HIGHEST,
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
key: new PluginKey('paste'),
props: {
// @ts-ignore
handlePaste: async (view, event: ClipboardEvent) => {
handlePaste: (view, event: ClipboardEvent) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.clipboardData) return false;
const files = Array.from(event.clipboardData.files);
if (files.length) {
event.preventDefault();
files.forEach((file) => {
handleFileEvent({ editor, file });
});
return true;
}
const text = event.clipboardData.getData('text/plain');
const html = event.clipboardData.getData('text/html');
const vscode = event.clipboardData.getData('vscode-editor-data');
@ -104,9 +51,7 @@ export const PasteMarkdown = Extension.create({
view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.codeBlock.create({
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
? vscodeMeta.mode
: null,
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode) ? vscodeMeta.mode : null,
})
)
);
@ -117,16 +62,46 @@ export const PasteMarkdown = Extension.create({
// 处理 markdown
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
event.preventDefault();
const paste = markdownSerializer.deserialize({
schema: view.props.state.schema,
// FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在
// const hasTitle = isTitleNode(view.props.state.doc.content.firstChild);
let schema = view.props.state.schema;
const doc = markdownSerializer.deserialize({
schema,
content: normalizePastedMarkdown(text),
});
// @ts-ignore
const transaction = view.state.tr.replaceSelectionWith(paste);
const transaction = view.state.tr.insert(view.state.selection.head, doc);
view.dispatch(transaction);
return true;
}
if (text.length !== 0) {
event.preventDefault();
view.dispatch(view.state.tr.insertText(text));
return true;
}
return false;
},
handleDrop: (view, event: any) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
const hasFiles = event.dataTransfer.files.length > 0;
if (!hasFiles) return false;
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length) {
event.preventDefault();
files.forEach((file: File) => {
handleFileEvent({ editor, file });
});
return true;
}
return false;
},
clipboardTextSerializer: (slice) => {

View File

@ -1,83 +0,0 @@
import { Plugin } from 'prosemirror-state';
import { Extension } from '@tiptap/core';
import { uploadFile } from 'services/file';
import { Attachment } from './attachment';
import { Image } from './image';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
};
export const PasteFile = Extension.create({
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// @ts-ignore
handlePaste: async (view, event: ClipboardEvent) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
if (!event.clipboardData) return false;
const file = event.clipboardData.files[0];
if (file) {
event.preventDefault();
const url = await uploadFile(file);
let node = null;
if (acceptedMimes.image.includes(file?.type)) {
node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
} else {
node = view.props.state.schema.nodes[Attachment.name].create({
url,
name: file.name,
});
}
const transaction = view.state.tr.replaceSelectionWith(node);
view.dispatch(transaction);
return true;
}
return false;
},
// @ts-ignore
handleDrop: async (view, event: any) => {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
const hasFiles = event.dataTransfer.files.length > 0;
if (!hasFiles) return false;
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
files.forEach(async (file: any) => {
if (!file) {
return;
}
const url = await uploadFile(file);
let node = null;
if (acceptedMimes.image.includes(file?.type)) {
node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
} else {
node = view.props.state.schema.nodes[Attachment.name].create({
url,
name: file.name,
});
}
const transaction = view.state.tr.replaceSelectionWith(node);
view.dispatch(transaction);
return true;
});
},
},
}),
];
},
});

View File

@ -2,6 +2,7 @@ import { Extension } from '@tiptap/core';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import scrollIntoView from 'scroll-into-view-if-needed';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@ -193,7 +194,7 @@ const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, g
setTimeout(() => {
const el = window.document.querySelector(`.${searchResultCurrentClass}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' });
}
}, 0);
@ -207,15 +208,17 @@ const gotoSearchResult = ({ view, tr, searchResults, searchResultCurrentClass, g
export const SearchNReplace = Extension.create<SearchOptions>({
name: 'search',
defaultOptions: {
searchTerm: '',
replaceTerm: '',
results: [],
currentIndex: 0,
searchResultClass: 'search-result',
searchResultCurrentClass: 'search-result-current',
caseSensitive: false,
disableRegex: false,
addOptions() {
return {
searchTerm: '',
replaceTerm: '',
results: [],
currentIndex: 0,
searchResultClass: 'search-result',
searchResultCurrentClass: 'search-result-current',
caseSensitive: false,
disableRegex: false,
};
},
addCommands() {

View File

@ -1,11 +1,11 @@
import { Node, Command, mergeAttributes } from '@tiptap/core';
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { StatusWrapper } from '../components/status';
declare module '@tiptap/core' {
interface Commands {
interface Commands<ReturnType> {
status: {
setStatus: () => Command;
setStatus: () => ReturnType;
};
}
}
@ -33,10 +33,7 @@ export const Status = Node.create({
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes),
];
return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)];
},
// @ts-ignore

View File

@ -1,14 +1,5 @@
import { Table as BuiltInTable } from '@tiptap/extension-table';
import { TableView } from '../views/tableView';
export const Table = BuiltInTable.extend({
// @ts-ignore
addOptions() {
return {
...this.parent?.(),
View: TableView,
};
},
}).configure({
resizable: true,
export const Table = BuiltInTable.configure({
resizable: false,
});

View File

@ -13,7 +13,6 @@ import {
selectRow,
selectTable,
} from '../services/table';
import { elementInViewport } from '../services/dom';
import { FloatMenuView } from '../views/floatMenuView';
export const TableCell = BuiltInTableCell.extend({
@ -27,27 +26,27 @@ export const TableCell = BuiltInTableCell.extend({
view: () =>
new FloatMenuView({
editor: this.editor,
tippyOptions: {
zIndex: 10000,
offset: [-28, 0],
},
shouldShow: ({ editor }, floatMenuView) => {
if (!editor.isEditable) {
return false;
}
if (isTableSelected(editor.state.selection)) {
return false;
}
const cells = getCellsInColumn(0)(editor.state.selection);
if (selectedRowIndex > -1) {
// 获取当前行的第一个单元格的位置
const rowCells = getCellsInRow(selectedRowIndex)(editor.state.selection);
if (rowCells && rowCells[0]) {
const node = editor.view.nodeDOM(rowCells[0].pos) as HTMLElement;
if (node) {
const el = node.querySelector('a.grip-row') as HTMLElement;
if (el) {
console.log({ el });
floatMenuView.parentNode = el;
// const intersectionObserver = new IntersectionObserver(function (entries) {
// console.log('ob');
// if (entries[0].intersectionRatio <= 0) {
// floatMenuView.hide();
// }
// });
// intersectionObserver.observe(el);
}
}
}

View File

@ -18,6 +18,9 @@ export const TableHeader = BuiltInTableHeader.extend({
view: () =>
new FloatMenuView({
editor: this.editor,
tippyOptions: {
zIndex: 10000,
},
shouldShow: ({ editor }) => {
if (!editor.isEditable) {
return false;
@ -44,7 +47,7 @@ export const TableHeader = BuiltInTableHeader.extend({
}}
/>
</Tooltip>
<Tooltip content="向后插入一列">
<Tooltip content="删除当前列">
<Button
size="small"
theme="borderless"
@ -55,7 +58,7 @@ export const TableHeader = BuiltInTableHeader.extend({
}}
/>
</Tooltip>
<Tooltip content="删除当前列" hideOnClick>
<Tooltip content="向后插入一列" hideOnClick>
<Button
size="small"
theme="borderless"

View File

@ -2,7 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core';
export const Title = Node.create({
name: 'title',
content: 'inline*',
content: 'text*',
selectable: true,
defining: true,
inline: false,

View File

@ -22,7 +22,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
}
return (
<>
<div>
<Space spacing={2}>
<MediaInsertMenu editor={editor} />
@ -77,7 +77,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
<LinkBubbleMenu editor={editor} />
<BannerBubbleMenu editor={editor} />
<TableBubbleMenu editor={editor} />
</>
</div>
);
};

View File

@ -0,0 +1,221 @@
import { Editor } from '@tiptap/core';
import { Space } from '@douyinfe/semi-ui';
import { IconList, IconOrderedList } from '@douyinfe/semi-icons';
import {
IconLink,
IconQuote,
IconHorizontalRule,
IconTask,
IconDocument,
IconMind,
IconTable,
IconImage,
IconCodeBlock,
IconStatus,
IconInfo,
IconAttachment,
IconMath,
} from 'components/icons';
export const EVOKE_MENU_ITEMS = [
{
key: '标题1',
label: '标题1',
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
key: '标题1',
label: '标题2',
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
key: '标题1',
label: '标题3',
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
key: '标题1',
label: '标题4',
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 4 }).run(),
},
{
key: '标题1',
label: '标题5',
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 5 }).run(),
},
{
key: '标题1',
label: '标题6',
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 6 }).run(),
},
{
key: '无序列表',
label: (
<Space>
<IconList />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleBulletList().run(),
},
{
key: '有序列表',
label: (
<Space>
<IconOrderedList />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
key: '任务列表',
label: (
<Space>
<IconTask />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleTaskList().run(),
},
{
key: '链接',
label: (
<Space>
<IconLink />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleLink({ href: '' }).run(),
},
{
key: '引用',
label: (
<Space>
<IconQuote />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleBlockquote().run(),
},
{
key: '分割线',
label: (
<Space>
<IconHorizontalRule />
线
</Space>
),
command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(),
},
{
key: '表格',
label: (
<Space>
<IconTable />
</Space>
),
command: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
{
key: '代码块',
label: (
<Space>
<IconCodeBlock />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
key: '图片',
label: () => (
<Space>
<IconImage />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setEmptyImage().run(),
},
{
key: '附件',
label: () => (
<Space>
<IconAttachment />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setAttachment().run(),
},
{
key: '外链',
label: (
<Space>
<IconLink />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setIframe({ url: '' }).run(),
},
{
key: '思维导图',
label: (
<Space>
<IconMind />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setMind().run(),
},
{
key: '数学公式',
label: (
<Space>
<IconMath />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setKatex().run(),
},
{
key: '状态',
label: (
<Space>
<IconStatus />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setStatus().run(),
},
{
key: '信息框',
label: (
<Space>
<IconInfo />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setBanner({ type: 'info' }).run(),
},
{
key: '文档',
label: (
<Space>
<IconDocument />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setDocumentReference().run(),
},
{
key: '子文档',
label: (
<Space>
<IconDocument />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setDocumentChildren().run(),
},
];

View File

@ -1,12 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui';
import {
IconAlignLeft,
IconAlignCenter,
IconAlignRight,
IconUpload,
IconDelete,
} from '@douyinfe/semi-icons';
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconUpload, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { Upload } from 'components/upload';
import { BubbleMenu } from './components/bubbleMenu';
@ -27,12 +21,14 @@ export const ImageBubbleMenu = ({ editor }) => {
setHeight(parseInt(currentHeight));
}, [currentWidth, currentHeight]);
console.log(attrs);
return (
<BubbleMenu
className={'bubble-menu'}
editor={editor}
pluginKey="image-bubble-menu"
shouldShow={() => editor.isActive(Image.name)}
shouldShow={() => editor.isActive(Image.name) && !!attrs.src}
tippyOptions={{
maxWidth: 456,
}}

View File

@ -1,8 +1,9 @@
import React from 'react';
import { Editor } from '@tiptap/core';
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 { Upload } from './components/upload';
import {
IconDocument,
IconMind,
@ -17,9 +18,9 @@ import {
} from 'components/icons';
import { GridSelect } from 'components/grid-select';
import { isTitleActive } from '../services/isActive';
import { getImageOriginSize } from '../services/image';
import { handleFileEvent } from '../services/upload';
export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
export const MediaInsertMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
if (!editor) {
return null;
}
@ -45,11 +46,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
<div style={{ padding: 0 }}>
<GridSelect
onSelect={({ rows, cols }) => {
return editor
.chain()
.focus()
.insertTable({ rows, cols, withHeaderRow: true })
.run();
return editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
}}
/>
</div>
@ -63,33 +60,20 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
<Dropdown.Item onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
<IconCodeBlock />
</Dropdown.Item>
<Dropdown.Item>
<Dropdown.Item onClick={() => editor.chain().focus().setEmptyImage().run()}>
<IconImage />
<Upload
accept="image/*"
onOK={async (url, fileName) => {
const { width, height } = await getImageOriginSize(url);
console.log('upload', width, height);
editor.chain().focus().setImage({ src: url, alt: fileName, width, height }).run();
}}
>
{() => '图片'}
</Upload>
</Dropdown.Item>
<Dropdown.Item>
<Dropdown.Item onClick={() => editor.chain().focus().setAttachment().run()}>
<IconAttachment />
<Upload
onOK={(url, name) => {
editor.chain().focus().setAttachment({ url, name }).run();
}}
>
{() => '附件'}
</Upload>
</Dropdown.Item>
<Dropdown.Item onClick={() => editor.chain().focus().insertIframe({ url: '' }).run()}>
<Dropdown.Item onClick={() => editor.chain().focus().setIframe({ url: '' }).run()}>
<IconLink />
</Dropdown.Item>
<Dropdown.Item onClick={() => editor.chain().focus().insertMind().run()}>
<Dropdown.Item onClick={() => editor.chain().focus().setMind().run()}>
<IconMind />
</Dropdown.Item>
@ -119,12 +103,7 @@ export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => {
>
<div>
<Tooltip content="插入">
<Button
type="tertiary"
theme="borderless"
icon={<IconPlus />}
disabled={isTitleActive(editor)}
/>
<Button type="tertiary" theme="borderless" icon={<IconPlus />} disabled={isTitleActive(editor)} />
</Tooltip>
</div>
</Dropdown>

View File

@ -11,6 +11,7 @@ import {
IconDeleteTable,
} from 'components/icons';
import { Tooltip } from 'components/tooltip';
import { Divider } from '../components/divider';
import { BubbleMenu } from './components/bubbleMenu';
import { Table } from '../extensions/table';
@ -24,9 +25,7 @@ export const TableBubbleMenu = ({ editor }) => {
tippyOptions={{
maxWidth: 456,
}}
matchRenderContainer={(node: HTMLElement) =>
node.classList && node.classList.contains('tableWrapper')
}
matchRenderContainer={(node: HTMLElement) => node.classList && node.tagName === 'TABLE'}
>
<Space>
<Tooltip content="向前插入一列">
@ -58,6 +57,8 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Divider />
<Tooltip content="向前插入一行">
<Button
onClick={() => editor.chain().focus().addRowBefore().run()}
@ -88,6 +89,8 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Divider />
<Tooltip content="合并单元格">
<Button
size="small"
@ -108,6 +111,8 @@ export const TableBubbleMenu = ({ editor }) => {
/>
</Tooltip>
<Divider />
<Tooltip content="删除表格" hideOnClick>
<Button
size="small"

View File

@ -0,0 +1,22 @@
import { EditorState } from 'prosemirror-state';
// @ts-ignore
import { lowlight } from 'lowlight';
import { isMarkActive } from './isActive';
export const LANGUAGES = lowlight.listLanguages().reduce((a, language) => {
a[language] = language;
return a;
}, {});
export function isInCode(state: EditorState): boolean {
if (state.schema.nodes.codeBlock) {
const $head = state.selection.$head;
for (let d = $head.depth; d > 0; d--) {
if ($head.node(d).type === state.schema.nodes.codeBlock) {
return true;
}
}
}
return isMarkActive(state.schema.marks.code)(state);
}

View File

@ -0,0 +1,40 @@
/**
*
*
* @example
* > extractFilename('https://gitlab.com/images/logo-full.png')
* < 'logo-full'
*
* @param {string} src The URL to extract filename from
* @returns {string}
*/
export const extractFilename = (src) => {
return src.replace(/^.*\/|\..+?$/g, '');
};
/**
*
* @param {string} fileName
* @returns {string}
*/
export const extractFileExtension = (fileName) => {
return fileName.split('.').pop();
};
export const readFileAsDataURL = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
reader.readAsDataURL(file);
});
};
export const normalizeFileSize = (size) => {
if (size < 1024) {
return size + ' Byte';
}
if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB';
}
return (size / 1024 / 1024).toFixed(2) + ' MB';
};

View File

@ -1,7 +1,19 @@
import { EditorState } from 'prosemirror-state';
export const isListActive = (editor) => {
return (
editor.isActive('bulletList') || editor.isActive('orderedList') || editor.isActive('taskList')
);
return editor.isActive('bulletList') || editor.isActive('orderedList') || editor.isActive('taskList');
};
export const isTitleActive = (editor) => editor.isActive('title');
export const isMarkActive =
(type) =>
(state: EditorState): boolean => {
if (!type) {
return false;
}
const { from, $from, to, empty } = state.selection;
return empty ? type.isInSet(state.storedMarks || $from.marks()) : state.doc.rangeHasMark(from, to, type);
};

View File

@ -0,0 +1,34 @@
export const isMarkdown = (text: string): boolean => {
// table
const tables = text.match(/^\|(\S)*\|/gm);
if (tables && tables.length) return true;
// code-ish
const fences = text.match(/^```/gm);
if (fences && fences.length > 1) return true;
// link-ish
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
// heading-ish
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
// list-ish
const listItems = text.match(/^[\d-*].?\s\S+/gm);
if (listItems && listItems.length > 1) return true;
//
return false;
};
export const normalizePastedMarkdown = (text: string): string => {
const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim;
while (text.match(CHECKBOX_REGEX)) {
text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`);
}
return text;
};

View File

@ -1,9 +1,6 @@
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import { sanitize } from 'dompurify';
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
} from 'prosemirror-markdown';
import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
import { markdown } from '.';
import { Attachment } from '../../extensions/attachment';
import { Banner } from '../../extensions/banner';

View File

@ -1,5 +1,9 @@
import { Node } from 'prosemirror-model';
export function isTitleNode(node: Node): boolean {
return node.type.name === 'title';
}
export function isBulletListNode(node: Node): boolean {
return node.type.name === 'bulletList';
}

View File

@ -0,0 +1,94 @@
import { Editor } from '@tiptap/core';
import { uploadFile } from 'services/file';
import { Loading } from '../extensions/loading';
import { Attachment } from '../extensions/attachment';
import { Image } from '../extensions/image';
import { extractFileExtension, extractFilename } from './file';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg', 'image/svg+xml'],
};
type FileInfo = {
fileName: string;
fileSize: number;
fileType: string;
fileExt: string;
};
interface FnProps {
file: File;
editor: Editor;
}
/**
*
* @param param0
*/
const uploadImage = async ({ file, fileInfo, editor }: FnProps & { fileInfo: FileInfo }) => {
const { view } = editor;
const { state } = view;
const { from } = state.selection;
const loadingNode = view.props.state.schema.nodes[Loading.name].create({
text: fileInfo.fileName,
});
view.dispatch(view.state.tr.replaceSelectionWith(loadingNode));
try {
const url = await uploadFile(file);
const node = view.props.state.schema.nodes[Image.name].create({
src: url,
});
const transaction = view.state.tr.replaceRangeWith(from, from + loadingNode.nodeSize, node);
view.dispatch(transaction);
} catch (e) {
editor.commands.deleteRange({ from: from, to: from + loadingNode.nodeSize });
console.log('上传文件失败!');
}
};
/**
*
* @param param0
*/
const uploadAttachment = async ({ file, fileInfo, editor }: FnProps & { fileInfo: FileInfo }) => {
const { view } = editor;
const { state } = view;
const { from } = state.selection;
const loadingNode = view.props.state.schema.nodes[Loading.name].create({
text: fileInfo.fileName,
});
view.dispatch(view.state.tr.replaceSelectionWith(loadingNode));
try {
const url = await uploadFile(file);
const node = view.props.state.schema.nodes[Attachment.name].create({
url,
...fileInfo,
});
const transaction = view.state.tr.replaceRangeWith(from, from + loadingNode.nodeSize, node);
view.dispatch(transaction);
} catch (e) {
editor.commands.deleteRange({ from: from, to: from + loadingNode.nodeSize });
console.log('上传文件失败!');
}
};
export const handleFileEvent = ({ file, editor }: FnProps) => {
if (!file) return false;
const fileInfo = {
fileName: extractFilename(file.name),
fileSize: file.size,
fileType: file.type,
fileExt: extractFileExtension(file.name),
};
if (acceptedMimes.image.includes(file?.type)) {
uploadImage({ file, editor, fileInfo });
return true;
}
uploadAttachment({ file, editor, fileInfo });
return true;
};

View File

@ -35,6 +35,7 @@ export class FloatMenuView {
private popup: Instance;
private _update: FloatMenuViewOptions['update'];
private shouldShow: FloatMenuViewOptions['shouldShow'];
private tippyOptions: FloatMenuViewOptions['tippyOptions'];
private getReferenceClientRect: NonNullable<FloatMenuViewOptions['getReferenceClientRect']> = ({
editor,
range,
@ -47,12 +48,16 @@ export class FloatMenuView {
return node.getBoundingClientRect();
}
}
if (this.parentNode) {
return this.parentNode.getBoundingClientRect();
}
return posToDOMRect(view, range.from, range.to);
};
constructor(props: FloatMenuViewOptions) {
this.editor = props.editor;
this.shouldShow = props.shouldShow;
this.tippyOptions = props.tippyOptions;
if (props.getReferenceClientRect) {
this.getReferenceClientRect = props.getReferenceClientRect;
}
@ -63,15 +68,25 @@ export class FloatMenuView {
props.init(this.dom, this.editor);
// popup
this.popup = tippy(document.body, {
appendTo: () => document.body,
this.createPopup();
}
createPopup() {
const { element: editorElement } = this.editor.options;
const editorIsAttached = !!editorElement.parentElement;
if (this.popup || !editorIsAttached) {
return;
}
this.popup = tippy(editorElement, {
getReferenceClientRect: null,
content: this.dom,
interactive: true,
trigger: 'manual',
placement: 'top',
hideOnClick: 'toggle',
...(props.tippyOptions ?? {}),
...(this.tippyOptions ?? {}),
});
}
@ -84,6 +99,8 @@ export class FloatMenuView {
return;
}
this.createPopup();
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
@ -118,10 +135,6 @@ export class FloatMenuView {
this.popup.setProps({
getReferenceClientRect: () => {
if (this.parentNode) {
return this.parentNode.getBoundingClientRect();
}
return this.getReferenceClientRect({
editor: this.editor,
oldState,
@ -137,14 +150,14 @@ export class FloatMenuView {
}
show() {
this.popup.show();
this.popup?.show();
}
hide() {
this.popup.hide();
this.popup?.hide();
}
public destroy() {
this.popup.destroy();
this.popup?.destroy();
}
}

View File

@ -1,103 +0,0 @@
// @ts-nocheck
import { NodeView } from 'prosemirror-view';
import { Node as ProseMirrorNode } from 'prosemirror-model';
export function updateColumns(
node: ProseMirrorNode,
colgroup: Element,
table: Element,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild;
const row = node.firstChild;
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j];
const cssWidth = hasWidth ? `${hasWidth}px` : '';
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
colgroup.appendChild(document.createElement('col')).style.width = cssWidth;
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth;
}
nextDOM = nextDOM.nextSibling;
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode.removeChild(nextDOM);
nextDOM = after;
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = '';
} else {
table.style.width = '';
table.style.minWidth = `${totalWidth}px`;
}
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
dom: Element;
table: Element;
colgroup: Element;
contentDOM: Element;
constructor(node: ProseMirrorNode, cellMinWidth: number) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement('div');
this.dom.className = 'tableWrapper';
this.innerDom = document.createElement('div');
this.innerDom.className = 'tableInnerWrapper';
this.dom.appendChild(this.innerDom);
this.table = this.innerDom.appendChild(document.createElement('table'));
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
updateColumns(node, this.colgroup, this.table, cellMinWidth);
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
return true;
}
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
return (
mutation.type === 'attributes' &&
(mutation.target === this.table || this.colgroup.contains(mutation.target))
);
}
}

View File

@ -8,7 +8,7 @@
overflow-x: auto;
&.table-bubble-menu {
transform: translateY(-1em);
transform: translateY(-2em);
}
}
@ -18,13 +18,11 @@
border-radius: 3px;
box-shadow: var(--box-shadow);
background-color: var(--semi-color-nav-bg);
transform: translateY(-1em);
&.row {
column-gap: 8px;
flex-direction: column;
transform: translate(-100%, 75%);
margin-left: -1.2em;
transform: translate(0, 70%);
}
}

View File

@ -199,17 +199,8 @@ a {
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;
}
.semi-spin-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}

View File

@ -299,17 +299,19 @@
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
min-width: 100%;
max-width: 100%;
margin: 1em 0;
td,
th {
position: relative;
border-width: 1px;
border-style: solid;
border-color: var(--semi-color-fill-2);
box-sizing: border-box;
min-width: 1em;
padding: 3px 5px;
position: relative;
vertical-align: top;
overflow: visible;
@ -320,7 +322,6 @@
th {
font-weight: bold;
text-align: left;
}
.selectedCell {
@ -330,68 +331,55 @@
}
.grip-column {
&::after {
content: '';
position: absolute;
display: block;
width: 100%;
height: 0.7em;
left: 0;
top: -1em;
margin-bottom: 3px;
cursor: pointer;
background: #ced4da;
}
position: absolute;
z-index: 10000;
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 {
&:hover,
&.selected {
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;
}
position: absolute;
z-index: 10000;
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 {
&:hover,
&.selected {
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;
}
position: absolute;
z-index: 10000;
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 {
&:hover,
&.selected {
background: var(--semi-color-info);
}
}

View File

@ -1,36 +1,36 @@
# 开发环境配置
server:
prefix: "/api"
prefix: '/api'
port: 5001
collaborationPort: 5003
client:
assetPrefix: "/"
apiUrl: "http://localhost:5001/api"
collaborationUrl: "ws://localhost:5003"
assetPrefix: '/'
apiUrl: 'http://localhost:5001/api'
collaborationUrl: 'ws://localhost:5003'
# 数据库配置
db:
mysql:
host: "127.0.0.1"
username: "root"
password: "root"
database: "think"
host: '127.0.0.1'
username: 'root'
password: 'root'
database: 'think'
port: 3306
charset: "utf8mb4"
timezone: "+08:00"
charset: 'utf8mb4'
timezone: '+08:00'
synchronize: true
# oss 文件存储服务
oss:
aliyun:
accessKeyId: "LTAI4Fc65yMwx2LR23PZwUed"
accessKeySecret: "I0EBFxRTWLyVk674raeYgC8h1tvSqG"
bucket: "wipi"
accessKeyId: 'LTAI5tNd19aX1TZJwGjKJWVE'
accessKeySecret: 'nlZJQprIO45QDeeANVlBZ6F7SIjBZs'
bucket: 'wipi'
https: true
region: "oss-cn-shanghai"
region: 'oss-cn-shanghai'
# jwt 配置
jwt:
secretkey: "zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022"
expiresIn: "6h"
secretkey: 'zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022'
expiresIn: '6h'

View File

@ -17,6 +17,7 @@ export class FileService {
* @param file
*/
async uploadFile(file) {
console.log('upload', file);
const { originalname, mimetype, size, buffer } = file;
const filename = `/${dateFormat(new Date(), 'yyyy-MM-dd')}/${uniqueid()}/${originalname}`;
const url = await this.ossClient.putFile(filename, buffer);