mirror of https://github.com/fantasticit/think.git
feat: support mention
This commit is contained in:
parent
6496422754
commit
4b151c3d8b
|
@ -35,6 +35,7 @@
|
||||||
"@tiptap/extension-italic": "^2.0.0-beta.25",
|
"@tiptap/extension-italic": "^2.0.0-beta.25",
|
||||||
"@tiptap/extension-link": "^2.0.0-beta.36",
|
"@tiptap/extension-link": "^2.0.0-beta.36",
|
||||||
"@tiptap/extension-list-item": "^2.0.0-beta.20",
|
"@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-ordered-list": "^2.0.0-beta.27",
|
||||||
"@tiptap/extension-paragraph": "^2.0.0-beta.23",
|
"@tiptap/extension-paragraph": "^2.0.0-beta.23",
|
||||||
"@tiptap/extension-placeholder": "^2.0.0-beta.47",
|
"@tiptap/extension-placeholder": "^2.0.0-beta.47",
|
||||||
|
|
|
@ -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 cls from 'classnames';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
import { BackTop } from '@douyinfe/semi-ui';
|
import { BackTop } from '@douyinfe/semi-ui';
|
||||||
|
@ -17,10 +18,13 @@ import {
|
||||||
getIndexdbProvider,
|
getIndexdbProvider,
|
||||||
destoryIndexdbProvider,
|
destoryIndexdbProvider,
|
||||||
} from 'tiptap';
|
} from 'tiptap';
|
||||||
|
import { findMentions } from 'tiptap/utils/find-mention';
|
||||||
|
import { useCollaborationDocument } from 'data/document';
|
||||||
import { DataRender } from 'components/data-render';
|
import { DataRender } from 'components/data-render';
|
||||||
import { Banner } from 'components/banner';
|
import { Banner } from 'components/banner';
|
||||||
import { debounce } from 'helpers/debounce';
|
import { debounce } from 'helpers/debounce';
|
||||||
import { event, triggerChangeDocumentTitle, triggerJoinUser, USE_DOCUMENT_VERSION } from 'event';
|
import { event, triggerChangeDocumentTitle, triggerJoinUser, USE_DOCUMENT_VERSION } from 'event';
|
||||||
|
import { DocumentUserSetting } from './users';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
@ -31,8 +35,10 @@ interface IProps {
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Editor: React.FC<IProps> = ({ user, documentId, authority, className, style }) => {
|
export const Editor: React.FC<IProps> = ({ user: currentUser, documentId, authority, className, style }) => {
|
||||||
if (!user) return null;
|
if (!currentUser) return null;
|
||||||
|
const $hasShowUserSettingModal = useRef(false);
|
||||||
|
const { users, addUser, updateUser } = useCollaborationDocument(documentId);
|
||||||
const [status, setStatus] = useState<ProviderStatus>('connecting');
|
const [status, setStatus] = useState<ProviderStatus>('connecting');
|
||||||
const { online } = useNetwork();
|
const { online } = useNetwork();
|
||||||
const [loading, toggleLoading] = useToggle(true);
|
const [loading, toggleLoading] = useToggle(true);
|
||||||
|
@ -40,9 +46,9 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
const provider = useMemo(() => {
|
const provider = useMemo(() => {
|
||||||
return getProvider({
|
return getProvider({
|
||||||
targetId: documentId,
|
targetId: documentId,
|
||||||
token: user.token,
|
token: currentUser.token,
|
||||||
cacheType: 'EDITOR',
|
cacheType: 'EDITOR',
|
||||||
user,
|
user: currentUser,
|
||||||
docType: 'document',
|
docType: 'document',
|
||||||
events: {
|
events: {
|
||||||
onAwarenessUpdate({ states }) {
|
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({
|
const editor = useEditor({
|
||||||
editable: authority && authority.editable,
|
editable: authority && authority.editable,
|
||||||
extensions: [
|
extensions: [
|
||||||
...BaseKit,
|
...BaseKit,
|
||||||
DocumentWithTitle,
|
DocumentWithTitle,
|
||||||
getCollaborationExtension(provider),
|
getCollaborationExtension(provider),
|
||||||
getCollaborationCursorExtension(provider, user),
|
getCollaborationCursorExtension(provider, currentUser),
|
||||||
],
|
],
|
||||||
onTransaction: debounce(({ transaction }) => {
|
onTransaction: debounce(({ transaction }) => {
|
||||||
try {
|
try {
|
||||||
|
@ -74,6 +79,8 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}, 50),
|
}, 50),
|
||||||
});
|
});
|
||||||
|
const [mentionUsersSettingVisible, toggleMentionUsersSettingVisible] = useToggle(false);
|
||||||
|
const [mentionUsers, setMentionUsers] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const indexdbProvider = getIndexdbProvider(documentId, provider.document);
|
const indexdbProvider = getIndexdbProvider(documentId, provider.document);
|
||||||
|
@ -96,11 +103,61 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const handler = (data) => editor.commands.setContent(data);
|
const handler = (data) => editor.commands.setContent(data);
|
||||||
event.on(USE_DOCUMENT_VERSION, handler);
|
event.on(USE_DOCUMENT_VERSION, handler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
event.off(USE_DOCUMENT_VERSION, handler);
|
event.off(USE_DOCUMENT_VERSION, handler);
|
||||||
};
|
};
|
||||||
}, [editor]);
|
}, [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 (
|
return (
|
||||||
<DataRender
|
<DataRender
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
@ -125,6 +182,14 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
|
||||||
</div>
|
</div>
|
||||||
<BackTop target={() => document.querySelector('#js-template-editor-container')} />
|
<BackTop target={() => document.querySelector('#js-template-editor-container')} />
|
||||||
</main>
|
</main>
|
||||||
|
<DocumentUserSetting
|
||||||
|
visible={mentionUsersSettingVisible}
|
||||||
|
toggleVisible={toggleMentionUsersSettingVisible}
|
||||||
|
mentionUsers={mentionUsers}
|
||||||
|
users={users}
|
||||||
|
addUser={addUser}
|
||||||
|
updateUser={updateUser}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 useSWR from 'swr';
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useAsyncLoading } from 'hooks/use-async-loading';
|
import { useAsyncLoading } from 'hooks/use-async-loading';
|
||||||
import { HttpClient } from 'services/http-client';
|
import { HttpClient } from 'services/http-client';
|
||||||
import { getPublicDocumentDetail } from 'services/document';
|
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 ICreateDocument = Partial<Pick<IDocument, 'wikiId' | 'parentDocumentId'>>;
|
||||||
type IDocumentWithAuth = { document: IDocument; authority: IAuthority };
|
type IDocumentWithAuth = { document: IDocument; authority: IAuthority };
|
||||||
type IUpdateDocument = Partial<Pick<IDocument, 'title' | 'content'>>;
|
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
|
* @param documentId
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const useCollaborationDocument = (documentId) => {
|
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}`,
|
`/document/user/${documentId}`,
|
||||||
(url) => HttpClient.get(url),
|
(url) => HttpClient.get(url),
|
||||||
{ shouldRetryOnError: false }
|
{ shouldRetryOnError: false }
|
||||||
|
@ -195,7 +192,7 @@ export const useCollaborationDocument = (documentId) => {
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = async (docAuth) => {
|
const updateUser = async (docAuth: DocAuth) => {
|
||||||
const ret = await HttpClient.post(`/document/user/${documentId}/update`, {
|
const ret = await HttpClient.post(`/document/user/${documentId}/update`, {
|
||||||
documentId,
|
documentId,
|
||||||
...docAuth,
|
...docAuth,
|
||||||
|
@ -204,7 +201,7 @@ export const useCollaborationDocument = (documentId) => {
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = async (docAuth) => {
|
const deleteUser = async (docAuth: DocAuth) => {
|
||||||
const ret = await HttpClient.post(`/document/user/${documentId}/delete`, {
|
const ret = await HttpClient.post(`/document/user/${documentId}/delete`, {
|
||||||
documentId,
|
documentId,
|
||||||
...docAuth,
|
...docAuth,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { getStorage, setStorage } from 'helpers/storage';
|
||||||
|
|
||||||
export const useUser = () => {
|
export const useUser = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data, error, mutate } = useSWR('user', getStorage);
|
const { data, error, mutate } = useSWR<ILoginUser>('user', getStorage);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
window.localStorage.removeItem('user');
|
window.localStorage.removeItem('user');
|
||||||
|
@ -29,7 +29,7 @@ export const useUser = () => {
|
||||||
|
|
||||||
const updateUser = async (patch: Pick<IUser, 'email' | 'avatar'>) => {
|
const updateUser = async (patch: Pick<IUser, 'email' | 'avatar'>) => {
|
||||||
const res = await HttpClient.patch('/user/update', patch);
|
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));
|
setStorage('user', JSON.stringify(ret));
|
||||||
mutate(ret);
|
mutate(ret);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import type { IUser } from '@think/domains';
|
import type { IPagination, IUser } from '@think/domains';
|
||||||
import { HttpClient } from './http-client';
|
import { HttpClient } from './http-client';
|
||||||
|
|
||||||
export const register = (data: Partial<IUser>): Promise<IUser> => {
|
export const register = (data: Partial<IUser>): Promise<IUser> => {
|
||||||
return HttpClient.post('/user/register', data);
|
return HttpClient.post('/user/register', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getUsers = (): Promise<IUser[]> => {
|
||||||
|
return HttpClient.get('/user');
|
||||||
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
|
@ -28,6 +28,7 @@ import { Katex } from './extensions/katex';
|
||||||
import { Link } from './extensions/link';
|
import { Link } from './extensions/link';
|
||||||
import { ListItem } from './extensions/listItem';
|
import { ListItem } from './extensions/listItem';
|
||||||
import { Loading } from './extensions/loading';
|
import { Loading } from './extensions/loading';
|
||||||
|
import { Mention } from './extensions/mention';
|
||||||
import { Mind } from './extensions/mind';
|
import { Mind } from './extensions/mind';
|
||||||
import { OrderedList } from './extensions/ordered-list';
|
import { OrderedList } from './extensions/ordered-list';
|
||||||
import { Paragraph } from './extensions/paragraph';
|
import { Paragraph } from './extensions/paragraph';
|
||||||
|
@ -84,6 +85,7 @@ export const BaseKit = [
|
||||||
Link,
|
Link,
|
||||||
ListItem,
|
ListItem,
|
||||||
Loading,
|
Loading,
|
||||||
|
Mention,
|
||||||
Mind,
|
Mind,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
|
@ -132,6 +134,7 @@ export const CommentKit = [
|
||||||
Katex,
|
Katex,
|
||||||
Link,
|
Link,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
Mention,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Placeholder,
|
Placeholder,
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
@import './heading.scss';
|
@import './heading.scss';
|
||||||
@import './katex.scss';
|
@import './katex.scss';
|
||||||
@import './list.scss';
|
@import './list.scss';
|
||||||
|
@import './mention.scss';
|
||||||
@import './menu.scss';
|
@import './menu.scss';
|
||||||
@import './mind.scss';
|
@import './mind.scss';
|
||||||
@import './placeholder.scss';
|
@import './placeholder.scss';
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
|
@ -12,7 +12,6 @@ interface IProps {
|
||||||
|
|
||||||
export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
const $container = useRef<HTMLDivElement>();
|
const $container = useRef<HTMLDivElement>();
|
||||||
const $image = useRef<HTMLInputElement>();
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
const selectItem = (index) => {
|
const selectItem = (index) => {
|
||||||
|
@ -35,10 +34,6 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
selectItem(selectedIndex);
|
selectItem(selectedIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectImage = function () {
|
|
||||||
console.log('image', this.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -27,4 +27,5 @@ export interface IAuthority {
|
||||||
userId: IUser['id'];
|
userId: IUser['id'];
|
||||||
readable: boolean;
|
readable: boolean;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
createUserId: IUser['id'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,4 +39,5 @@ export interface IAuthority {
|
||||||
userId: IUser['id'];
|
userId: IUser['id'];
|
||||||
readable: boolean;
|
readable: boolean;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
createUserId: IUser['id'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Request,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
Get,
|
||||||
Patch,
|
Patch,
|
||||||
ClassSerializerInterceptor,
|
ClassSerializerInterceptor,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
@ -42,4 +43,12 @@ export class UserController {
|
||||||
async updateUser(@Request() req, @Body() dto: UpdateUserDto) {
|
async updateUser(@Request() req, @Body() dto: UpdateUserDto) {
|
||||||
return await this.userService.updateUser(req.user, dto);
|
return await this.userService.updateUser(req.user, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseInterceptors(ClassSerializerInterceptor)
|
||||||
|
@Get('/')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async getUsers() {
|
||||||
|
return this.userService.getUsers();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -149,4 +149,15 @@ export class UserService {
|
||||||
const user = this.jwtService.decode(token) as UserEntity;
|
const user = this.jwtService.decode(token) as UserEntity;
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户列表
|
||||||
|
* @param pagination
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getUsers() {
|
||||||
|
const query = this.userRepo.createQueryBuilder('user');
|
||||||
|
const [data] = await query.getManyAndCount();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,7 @@ importers:
|
||||||
'@tiptap/extension-italic': ^2.0.0-beta.25
|
'@tiptap/extension-italic': ^2.0.0-beta.25
|
||||||
'@tiptap/extension-link': ^2.0.0-beta.36
|
'@tiptap/extension-link': ^2.0.0-beta.36
|
||||||
'@tiptap/extension-list-item': ^2.0.0-beta.20
|
'@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-ordered-list': ^2.0.0-beta.27
|
||||||
'@tiptap/extension-paragraph': ^2.0.0-beta.23
|
'@tiptap/extension-paragraph': ^2.0.0-beta.23
|
||||||
'@tiptap/extension-placeholder': ^2.0.0-beta.47
|
'@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-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-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-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-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-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
|
'@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
|
'@tiptap/core': 2.0.0-beta.171
|
||||||
dev: false
|
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:
|
/@tiptap/extension-ordered-list/2.0.0-beta.27_@tiptap+core@2.0.0-beta.171:
|
||||||
resolution: {integrity: sha512-apFDeignxdZb3cA3p1HJu0zw1JgJdBYUBz1r7f99qdNybYuk3I/1MPUvlOuOgvIrBB/wydoyVDP+v9F7QN3tfQ==}
|
resolution: {integrity: sha512-apFDeignxdZb3cA3p1HJu0zw1JgJdBYUBz1r7f99qdNybYuk3I/1MPUvlOuOgvIrBB/wydoyVDP+v9F7QN3tfQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
Loading…
Reference in New Issue