feat: support mention

This commit is contained in:
fantasticit 2022-04-05 13:33:45 +08:00
parent 6496422754
commit 4b151c3d8b
19 changed files with 525 additions and 28 deletions

View File

@ -35,6 +35,7 @@
"@tiptap/extension-italic": "^2.0.0-beta.25",
"@tiptap/extension-link": "^2.0.0-beta.36",
"@tiptap/extension-list-item": "^2.0.0-beta.20",
"@tiptap/extension-mention": "^2.0.0-beta.95",
"@tiptap/extension-ordered-list": "^2.0.0-beta.27",
"@tiptap/extension-paragraph": "^2.0.0-beta.23",
"@tiptap/extension-placeholder": "^2.0.0-beta.47",

View File

@ -1,4 +1,5 @@
import React, { useMemo, useEffect, useState } from 'react';
import Router from 'next/router';
import React, { useMemo, useEffect, useState, useRef } from 'react';
import cls from 'classnames';
import { useEditor, EditorContent } from '@tiptap/react';
import { BackTop } from '@douyinfe/semi-ui';
@ -17,10 +18,13 @@ import {
getIndexdbProvider,
destoryIndexdbProvider,
} from 'tiptap';
import { findMentions } from 'tiptap/utils/find-mention';
import { useCollaborationDocument } from 'data/document';
import { DataRender } from 'components/data-render';
import { Banner } from 'components/banner';
import { debounce } from 'helpers/debounce';
import { event, triggerChangeDocumentTitle, triggerJoinUser, USE_DOCUMENT_VERSION } from 'event';
import { DocumentUserSetting } from './users';
import styles from './index.module.scss';
interface IProps {
@ -31,8 +35,10 @@ interface IProps {
style: React.CSSProperties;
}
export const Editor: React.FC<IProps> = ({ user, documentId, authority, className, style }) => {
if (!user) return null;
export const Editor: React.FC<IProps> = ({ user: currentUser, documentId, authority, className, style }) => {
if (!currentUser) return null;
const $hasShowUserSettingModal = useRef(false);
const { users, addUser, updateUser } = useCollaborationDocument(documentId);
const [status, setStatus] = useState<ProviderStatus>('connecting');
const { online } = useNetwork();
const [loading, toggleLoading] = useToggle(true);
@ -40,9 +46,9 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
const provider = useMemo(() => {
return getProvider({
targetId: documentId,
token: user.token,
token: currentUser.token,
cacheType: 'EDITOR',
user,
user: currentUser,
docType: 'document',
events: {
onAwarenessUpdate({ states }) {
@ -57,15 +63,14 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
},
},
});
}, [documentId, user.token]);
}, [documentId, currentUser.token]);
const editor = useEditor({
editable: authority && authority.editable,
extensions: [
...BaseKit,
DocumentWithTitle,
getCollaborationExtension(provider),
getCollaborationCursorExtension(provider, user),
getCollaborationCursorExtension(provider, currentUser),
],
onTransaction: debounce(({ transaction }) => {
try {
@ -74,6 +79,8 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
} catch (e) {}
}, 50),
});
const [mentionUsersSettingVisible, toggleMentionUsersSettingVisible] = useToggle(false);
const [mentionUsers, setMentionUsers] = useState([]);
useEffect(() => {
const indexdbProvider = getIndexdbProvider(documentId, provider.document);
@ -96,11 +103,61 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
if (!editor) return;
const handler = (data) => editor.commands.setContent(data);
event.on(USE_DOCUMENT_VERSION, handler);
return () => {
event.off(USE_DOCUMENT_VERSION, handler);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const handler = () => {
// 已经拦截过一次,不再拦截
if ($hasShowUserSettingModal.current) return;
const mentionUsers = findMentions(editor);
if (!mentionUsers || !mentionUsers.length) return;
const currentUserAuth = users.find((user) => {
return user.user.name === currentUser.name;
});
const isCurrentUserCreateUser = currentUserAuth.auth.createUserId === currentUser.id;
if (!isCurrentUserCreateUser) {
return;
}
const data = Array.from(new Set(mentionUsers))
.filter((userName) => {
const exist = users.find((user) => {
return user.user.name === userName;
});
if (!exist || !exist.auth.readable) return true;
return false;
})
.filter(Boolean);
if (!data.length) return;
setMentionUsers(data);
toggleMentionUsersSettingVisible(true);
$hasShowUserSettingModal.current = true;
// ignore-me
const newErr = new Error('请完成权限操作后关闭页面');
throw newErr;
};
Router.events.on('routeChangeStart', handler);
window.addEventListener('unload', handler);
return () => {
$hasShowUserSettingModal.current = false;
Router.events.off('routeChangeStart', handler);
window.removeEventListener('unload', handler);
};
}, [editor, users, currentUser]);
return (
<DataRender
loading={loading}
@ -125,6 +182,14 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
</div>
<BackTop target={() => document.querySelector('#js-template-editor-container')} />
</main>
<DocumentUserSetting
visible={mentionUsersSettingVisible}
toggleVisible={toggleMentionUsersSettingVisible}
mentionUsers={mentionUsers}
users={users}
addUser={addUser}
updateUser={updateUser}
/>
</div>
);
}}

View File

@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import { Modal, Typography, Table, Checkbox } from '@douyinfe/semi-ui';
import { IAuthority, IUser } from '@think/domains';
import { DocAuth } from 'data/document';
interface IProps {
visible: boolean;
toggleVisible: (arg: boolean) => void;
mentionUsers: string[];
users: Array<{ user: IUser; auth: IAuthority }>;
addUser: (auth: DocAuth) => Promise<unknown>;
updateUser: (auth: DocAuth) => Promise<unknown>;
}
const { Text } = Typography;
const { Column } = Table;
const renderChecked = (onChange, authKey: 'readable' | 'editable') => (checked, data) => {
const handle = (evt) => {
const ret = {
...data,
};
ret[authKey] = evt.target.checked;
onChange(ret);
};
return <Checkbox style={{ display: 'inline-block' }} checked={checked} onChange={handle} />;
};
export const DocumentUserSetting: React.FC<IProps> = ({
visible,
toggleVisible,
mentionUsers,
users,
addUser,
updateUser,
}) => {
const renderUsers = useMemo(() => {
return mentionUsers
.map((mentionUser) => {
const exist = users.find((user) => {
return user.user.name === mentionUser;
});
if (!exist) return { userName: mentionUser, readable: false, editable: false, shouldAddToDocument: true };
return {
userName: mentionUser,
readable: exist.auth.readable,
editable: exist.auth.editable,
shouldAddToDocument: false,
};
})
.filter(Boolean);
}, [users, mentionUsers]);
const handler = async (data) => {
if (data.shouldAddToDocument) {
await addUser(data.userName);
}
await updateUser(data);
};
return (
<Modal
title={'权限操作'}
visible={visible}
onCancel={() => toggleVisible(false)}
maskClosable={false}
style={{ maxWidth: '96vw' }}
footer={null}
>
<Text> @ </Text>
<Table style={{ margin: '24px 0' }} dataSource={renderUsers} size="small" pagination>
<Column title="用户名" dataIndex="userName" key="name" />
<Column
title="是否可读"
dataIndex="readable"
key="readable"
render={renderChecked(handler, 'readable')}
align="center"
/>
<Column
title="是否可编辑"
dataIndex="editable"
key="editable"
render={renderChecked(handler, 'editable')}
align="center"
/>
</Table>
</Modal>
);
};

View File

@ -1,18 +1,10 @@
import type { IUser, IDocument, IWiki } from '@think/domains';
import type { IUser, IDocument, IWiki, IAuthority } from '@think/domains';
import useSWR from 'swr';
import { useState, useCallback, useEffect } from 'react';
import { useAsyncLoading } from 'hooks/use-async-loading';
import { HttpClient } from 'services/http-client';
import { getPublicDocumentDetail } from 'services/document';
interface IAuthority {
id: string;
documentId: string;
userId: string;
readable: boolean;
editable: boolean;
}
type ICreateDocument = Partial<Pick<IDocument, 'wikiId' | 'parentDocumentId'>>;
type IDocumentWithAuth = { document: IDocument; authority: IAuthority };
type IUpdateDocument = Partial<Pick<IDocument, 'title' | 'content'>>;
@ -171,13 +163,18 @@ export const usePublicDocument = (documentId: string) => {
};
};
export type DocAuth = {
userName: string;
readable?: boolean;
editable?: boolean;
};
/**
*
* @param documentId
* @returns
*/
export const useCollaborationDocument = (documentId) => {
const { data, error, mutate } = useSWR<Array<IAuthority & IUser>>(
const { data, error, mutate } = useSWR<Array<{ user: IUser; auth: IAuthority }>>(
`/document/user/${documentId}`,
(url) => HttpClient.get(url),
{ shouldRetryOnError: false }
@ -195,7 +192,7 @@ export const useCollaborationDocument = (documentId) => {
return ret;
};
const updateUser = async (docAuth) => {
const updateUser = async (docAuth: DocAuth) => {
const ret = await HttpClient.post(`/document/user/${documentId}/update`, {
documentId,
...docAuth,
@ -204,7 +201,7 @@ export const useCollaborationDocument = (documentId) => {
return ret;
};
const deleteUser = async (docAuth) => {
const deleteUser = async (docAuth: DocAuth) => {
const ret = await HttpClient.post(`/document/user/${documentId}/delete`, {
documentId,
...docAuth,

View File

@ -7,7 +7,7 @@ import { getStorage, setStorage } from 'helpers/storage';
export const useUser = () => {
const router = useRouter();
const { data, error, mutate } = useSWR('user', getStorage);
const { data, error, mutate } = useSWR<ILoginUser>('user', getStorage);
const logout = useCallback(() => {
window.localStorage.removeItem('user');
@ -29,7 +29,7 @@ export const useUser = () => {
const updateUser = async (patch: Pick<IUser, 'email' | 'avatar'>) => {
const res = await HttpClient.patch('/user/update', patch);
const ret = { ...data, ...res } as unknown as IUser;
const ret = { ...data, ...res } as unknown as ILoginUser;
setStorage('user', JSON.stringify(ret));
mutate(ret);
};

View File

@ -1,6 +1,10 @@
import type { IUser } from '@think/domains';
import type { IPagination, IUser } from '@think/domains';
import { HttpClient } from './http-client';
export const register = (data: Partial<IUser>): Promise<IUser> => {
return HttpClient.post('/user/register', data);
};
export const getUsers = (): Promise<IUser[]> => {
return HttpClient.get('/user');
};

View File

@ -0,0 +1,95 @@
import BulitInMention from '@tiptap/extension-mention';
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { getUsers } from 'services/user';
import { MentionList } from '../wrappers/mention-list';
const suggestion = {
items: async ({ query }) => {
const res = await getUsers();
const data = res.map((item) => item.name);
return data.filter((item) => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
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();
},
};
},
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(' ');
if (overrideSpace) {
range.to += 1;
}
console.log('mention', props);
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: BulitInMention.name,
attrs: props,
},
{
type: 'text',
text: ' ',
},
])
.run();
},
};
export const Mention = BulitInMention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion,
});

View File

@ -28,6 +28,7 @@ import { Katex } from './extensions/katex';
import { Link } from './extensions/link';
import { ListItem } from './extensions/listItem';
import { Loading } from './extensions/loading';
import { Mention } from './extensions/mention';
import { Mind } from './extensions/mind';
import { OrderedList } from './extensions/ordered-list';
import { Paragraph } from './extensions/paragraph';
@ -84,6 +85,7 @@ export const BaseKit = [
Link,
ListItem,
Loading,
Mention,
Mind,
OrderedList,
Paragraph,
@ -132,6 +134,7 @@ export const CommentKit = [
Katex,
Link,
ListItem,
Mention,
OrderedList,
Paragraph,
Placeholder,

View File

@ -5,6 +5,7 @@
@import './heading.scss';
@import './katex.scss';
@import './list.scss';
@import './mention.scss';
@import './menu.scss';
@import './mind.scss';
@import './placeholder.scss';

View File

@ -0,0 +1,7 @@
.mention {
color: #fff;
padding: 2px 6px;
background-color: var(--semi-color-primary);
border: 1px solid var(--semi-color-border);
border-radius: 999em;
}

View File

@ -0,0 +1,22 @@
import { Editor } from '@tiptap/core';
import { Mention } from '../extensions/mention';
export const findMentions = (editor: Editor) => {
const content = editor.getJSON();
const queue = [content];
const res = [];
while (queue.length) {
const node = queue.shift();
if (node.type === Mention.name) {
res.push(node.attrs.id);
}
if (node.content && node.content.length) {
queue.push(...node.content);
}
}
return res;
};

View File

@ -0,0 +1,42 @@
.items {
width: 200px;
max-height: 380px;
overflow-x: hidden;
overflow-y: auto;
background-color: var(--semi-color-bg-0);
border-radius: var(--border-radius);
box-shadow: rgb(9 30 66 / 31%) 0 0 1px, rgb(9 30 66 / 25%) 0 4px 8px -2px;
}
.item {
display: flex;
width: 100%;
padding: 12px 12px 11px;
color: rgb(9 30 66);
text-decoration: none;
cursor: pointer;
background-color: rgb(255 255 255);
border: 0;
border-radius: 0;
outline: 0;
flex: 0 0 auto;
align-items: center;
box-sizing: border-box;
fill: rgb(255 255 255);
&:hover {
background-color: #f4f5f7;
}
&.is-selected {
color: rgb(0 82 204);
text-decoration: none;
background-color: rgb(222 235 255);
fill: rgb(222 235 255);
}
img {
width: 1em;
height: 1em;
}
}

View File

@ -0,0 +1,137 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react';
import { Modal, Typography } from '@douyinfe/semi-ui';
import { useRouter } from 'next/router';
import { Editor } from '@tiptap/core';
import cls from 'classnames';
import scrollIntoView from 'scroll-into-view-if-needed';
import { useUser } from 'data/user';
import { useCollaborationDocument } from 'data/document';
import styles from './index.module.scss';
interface IProps {
editor: Editor;
items: Array<string>;
command: any;
}
const { Title, Text } = Typography;
export const MentionList: React.FC<IProps> = forwardRef((props, ref) => {
const $container = useRef<HTMLDivElement>();
const router = useRouter();
const { user: currentUser } = useUser();
const { users, addUser, updateUser } = useCollaborationDocument(router.query.documentId);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const userName = props.items[index];
if (!userName) return;
props.command({ id: userName });
// const currentUserAuth = users.find((user) => {
// return user.user.name === currentUser.name;
// });
// const isCurrentUserCreateUser = currentUserAuth.auth.createUserId === currentUser.id;
// const target = users.find((user) => {
// return user.user.name === userName;
// });
// if (isCurrentUserCreateUser) {
// if (!target) {
// Modal.confirm({
// title: <Title heading={5}>权限操作</Title>,
// content: <Text>当前用户尚未加入该文档,是否添加他?</Text>,
// onCancel: () => {},
// onOk: async () => {
// addUser(userName).then((res) => {
// console.log('用户已经添加', res);
// props.command({ id: userName });
// });
// },
// });
// } else {
// if (!target.auth.readable) {
// Modal.confirm({
// title: <Title heading={5}>权限操作</Title>,
// content: <Text>当前用户无法阅读该文档,是否添加阅读权限?</Text>,
// onCancel: () => {},
// onOk: async () => {
// updateUser({
// userName,
// readable: true,
// editable: target.auth.editable,
// }).then((res) => {
// props.command({ id: userName });
// });
// },
// });
// } else {
// props.command({ id: userName });
// }
// }
// } else {
// }
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useEffect(() => {
if (Number.isNaN(selectedIndex + 1)) return;
const el = $container.current.querySelector(`button:nth-of-type(${selectedIndex + 1})`);
el && scrollIntoView(el, { behavior: 'smooth', scrollMode: 'if-needed' });
}, [selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className={styles.items}>
<div ref={$container}>
{props.items.length ? (
props.items.map((item, index) => (
<button
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
key={index}
onClick={() => selectItem(index)}
>
{item}
</button>
))
) : (
<div className={styles.item}></div>
)}
</div>
</div>
);
});

View File

@ -12,7 +12,6 @@ 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) => {
@ -35,10 +34,6 @@ 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

@ -27,4 +27,5 @@ export interface IAuthority {
userId: IUser['id'];
readable: boolean;
editable: boolean;
createUserId: IUser['id'];
}

View File

@ -39,4 +39,5 @@ export interface IAuthority {
userId: IUser['id'];
readable: boolean;
editable: boolean;
createUserId: IUser['id'];
}

View File

@ -7,6 +7,7 @@ import {
Request,
UseGuards,
UseInterceptors,
Get,
Patch,
ClassSerializerInterceptor,
} from '@nestjs/common';
@ -42,4 +43,12 @@ export class UserController {
async updateUser(@Request() req, @Body() dto: UpdateUserDto) {
return await this.userService.updateUser(req.user, dto);
}
@UseInterceptors(ClassSerializerInterceptor)
@Get('/')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async getUsers() {
return this.userService.getUsers();
}
}

View File

@ -149,4 +149,15 @@ export class UserService {
const user = this.jwtService.decode(token) as UserEntity;
return user;
}
/**
*
* @param pagination
* @returns
*/
async getUsers() {
const query = this.userRepo.createQueryBuilder('user');
const [data] = await query.getManyAndCount();
return data;
}
}

View File

@ -61,6 +61,7 @@ importers:
'@tiptap/extension-italic': ^2.0.0-beta.25
'@tiptap/extension-link': ^2.0.0-beta.36
'@tiptap/extension-list-item': ^2.0.0-beta.20
'@tiptap/extension-mention': ^2.0.0-beta.95
'@tiptap/extension-ordered-list': ^2.0.0-beta.27
'@tiptap/extension-paragraph': ^2.0.0-beta.23
'@tiptap/extension-placeholder': ^2.0.0-beta.47
@ -143,6 +144,7 @@ importers:
'@tiptap/extension-italic': 2.0.0-beta.25_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-link': 2.0.0-beta.36_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-list-item': 2.0.0-beta.20_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-mention': 2.0.0-beta.95_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-ordered-list': 2.0.0-beta.27_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-paragraph': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-placeholder': 2.0.0-beta.47_@tiptap+core@2.0.0-beta.171
@ -1740,6 +1742,17 @@ packages:
'@tiptap/core': 2.0.0-beta.171
dev: false
/@tiptap/extension-mention/2.0.0-beta.95_@tiptap+core@2.0.0-beta.171:
resolution: {integrity: sha512-AiikYJa33APtMI7c6a4EpPhqAYxHHnub5b9hd62zM1peBp2D2aO1dEIL7cB7O2P8EBZ2pu2QN2mFcCbVlSi0Xw==}
peerDependencies:
'@tiptap/core': ^2.0.0-beta.1
dependencies:
'@tiptap/core': 2.0.0-beta.171
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
prosemirror-model: 1.16.1
prosemirror-state: 1.3.4
dev: false
/@tiptap/extension-ordered-list/2.0.0-beta.27_@tiptap+core@2.0.0-beta.171:
resolution: {integrity: sha512-apFDeignxdZb3cA3p1HJu0zw1JgJdBYUBz1r7f99qdNybYuk3I/1MPUvlOuOgvIrBB/wydoyVDP+v9F7QN3tfQ==}
peerDependencies: