feat: improve editor

This commit is contained in:
fantasticit 2022-03-23 20:42:47 +08:00
parent 3f981d44b0
commit 612754fe4b
20 changed files with 188 additions and 98 deletions

View File

@ -53,7 +53,6 @@
"@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/react": "^2.0.0-beta.107",
"@tiptap/suggestion": "^2.0.0-beta.90",
"@traptitech/markdown-it-katex": "^3.5.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.1",
@ -66,11 +65,8 @@
"markdown-it-anchor": "^8.4.1",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"markdown-it-footnote": "^3.0.3",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"markdown-it-task-lists": "^2.1.1",
"marked": "^4.0.12",
"next": "12.0.10",
"prosemirror-markdown": "^1.7.0",
"prosemirror-tables": "^1.1.1",

View File

@ -78,8 +78,8 @@ export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId })
CollaborationEventEmitter.on(KEY, ({ states: users }) => {
const newCollaborationUsers = users
.filter(Boolean)
.map((state) => ({ ...state.user, clientId: state.clientId }))
.filter(Boolean);
.filter((state) => state.user)
.map((state) => ({ ...state.user, clientId: state.clientId }));
if (
collaborationUsers.length === newCollaborationUsers.length &&

View File

@ -6,7 +6,6 @@ import { ILoginUser, IAuthority } from '@think/domains';
import { useToggle } from 'hooks/useToggle';
import {
DEFAULT_EXTENSION,
Document,
DocumentWithTitle,
getCollaborationExtension,
getCollaborationCursorExtension,
@ -16,6 +15,8 @@ import {
} from 'components/tiptap';
import { DataRender } from 'components/data-render';
import { joinUser } from 'components/document/collaboration';
import { debounce } from 'helpers/debounce';
import { changeTitle } from './index';
import styles from './index.module.scss';
interface IProps {
@ -44,10 +45,6 @@ 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: [
@ -56,10 +53,12 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
getCollaborationExtension(provider),
getCollaborationCursorExtension(provider, user),
],
editorProps: {
// @ts-ignore
noTitleEditor,
},
onTransaction: debounce(({ transaction }) => {
try {
const title = transaction.doc.content.firstChild.content.firstChild.textContent;
changeTitle(title);
} catch (e) {}
}, 200),
});
const [loading, toggleLoading] = useToggle(true);
@ -68,8 +67,6 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
toggleLoading(false);
});
// provid
provider.on('status', async ({ status }) => {
console.log('status', status);
});

View File

@ -9,6 +9,7 @@
z-index: 110;
background-color: var(--semi-color-nav-bg);
height: 60px;
user-select: none;
> div {
overflow: auto;
@ -39,6 +40,7 @@
overflow: hidden;
background-color: var(--semi-color-nav-bg);
border-bottom: 1px solid var(--semi-color-border);
user-select: none;
&.isStandardWidth {
justify-content: center;

View File

@ -1,6 +1,6 @@
import Router from 'next/router';
import React, { useCallback, useMemo } from 'react';
import { Layout, Nav, Skeleton, Typography, Space, Button, Tooltip, Spin, Popover } from '@douyinfe/semi-ui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Nav, Skeleton, Typography, Space, Button, Tooltip, Spin, Popover } from '@douyinfe/semi-ui';
import { IconChevronLeft, IconArticle } from '@douyinfe/semi-icons';
import { useUser } from 'data/user';
import { useDocumentDetail } from 'data/document';
@ -13,12 +13,19 @@ import { DocumentStar } from 'components/document/star';
import { DocumentCollaboration } from 'components/document/collaboration';
import { DocumentStyle } from 'components/document/style';
import { useDocumentStyle } from 'hooks/useDocumentStyle';
import { EventEmitter } from 'helpers/event-emitter';
import { Editor } from './editor';
import styles from './index.module.scss';
const { Header, Content } = Layout;
const { Text } = Typography;
const em = new EventEmitter();
const TITLE_CHANGE_EVENT = 'TITLE_CHANGE_EVENT';
export const changeTitle = (title) => {
em.emit(TITLE_CHANGE_EVENT, title);
};
interface IProps {
documentId: string;
}
@ -30,7 +37,7 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
const editorWrapClassNames = useMemo(() => {
return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth;
}, [width]);
const [title, setTitle] = useState('');
const { user } = useUser();
const { data: documentAndAuth, loading: docAuthLoading, error: docAuthError } = useDocumentDetail(documentId);
const { document, authority } = documentAndAuth || {};
@ -54,13 +61,21 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
}
normalContent={() => (
<Text ellipsis={{ showTooltip: true }} style={{ width: ~~(windowWith / 4) }}>
{document.title}
{title}
</Text>
)}
/>
</>
);
useEffect(() => {
em.on(TITLE_CHANGE_EVENT, setTitle);
return () => {
em.destroy();
};
}, []);
return (
<div className={styles.wrap}>
<header>
@ -91,10 +106,9 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
<Spin></Spin>
</div>
}
error={null}
error={docAuthError}
normalContent={() => {
return (
// <div style={{ fontSize }}>
<>
<Seo title={document.title} />
<Editor

View File

@ -9,6 +9,7 @@
z-index: 110;
background-color: var(--semi-color-nav-bg);
height: 60px;
user-select: none;
> div {
overflow: auto;
@ -39,6 +40,7 @@
overflow: hidden;
background-color: var(--semi-color-nav-bg);
border-bottom: 1px solid var(--semi-color-border);
user-select: none;
&.isStandardWidth {
> div {

View File

@ -1,6 +1,6 @@
.items {
width: 160px;
max-height: 50vh;
max-height: 40vh;
overflow: auto;
padding: 0.2rem;
position: relative;

View File

@ -0,0 +1,5 @@
.wrap {
margin: 8px 0;
display: inline-flex;
justify-content: center;
}

View File

@ -0,0 +1,23 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { useEffect, useMemo } from 'react';
import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import katex from 'katex';
import { Checkbox } from '@douyinfe/semi-ui';
import styles from './index.module.scss';
const { Text } = Typography;
export const TaskItemWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable;
const { checked } = node.attrs;
console.log(node.attrs);
return (
<NodeViewWrapper as="span" className={styles.wrap} contentEditable={false}>
<Checkbox checked={checked} onChange={(e) => updateAttributes({ checked: e.target.checked })} />
<NodeViewContent></NodeViewContent>
</NodeViewWrapper>
);
};

View File

@ -11,7 +11,7 @@ const attrs = {
bdo: ['dir'],
};
export const HTMLMarks = marks.map(({ name, tag }) =>
export const HTMLMarks = marks.slice(1).map(({ name, tag }) =>
Mark.create({
name,
tag,

View File

@ -116,7 +116,8 @@ export const Paste = Extension.create({
return false;
},
clipboardTextSerializer: (slice) => {
const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content);
const doc = slice.content;
if (!doc) {
return '';
}

View File

@ -40,7 +40,7 @@ export const Table = BuiltInTable.extend({
if (fixedWidth && totalWidth > 0) {
HTMLAttributes.style = `width: ${totalWidth}px;`;
} else if (totalWidth && totalWidth > 0) {
HTMLAttributes.style = `min-width: ${totalWidth}px`;
HTMLAttributes.style = `min-width: 100%`;
} else {
HTMLAttributes.style = null;
}

View File

@ -1,8 +1,10 @@
import { wrappingInputRule, mergeAttributes } from '@tiptap/core';
import { wrappingInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
import { Plugin } from 'prosemirror-state';
import { findParentNodeClosestToPos } from 'prosemirror-utils';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import { TaskItemWrapper } from '../components/taskItem';
const CustomTaskItem = BuiltInTaskItem.extend({
parseHTML() {
@ -27,35 +29,79 @@ const CustomTaskItem = BuiltInTaskItem.extend({
];
},
// addProseMirrorPlugins() {
// return [
// new Plugin({
// props: {
// // @ts-ignore
// handleClick: (view, pos, event) => {
// const state = view.state;
// const schema = state.schema;
// const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
// const position = state.doc.resolve(coordinates.pos);
// const parentList = findParentNodeClosestToPos(position, function (node) {
// return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem;
// });
// // @ts-ignore
// const isListClicked = event.target.tagName.toLowerCase() === 'li';
// if (!isListClicked || !parentList || parentList.node.type !== schema.nodes.taskItem) {
// return;
// }
// const tr = state.tr;
// tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
// checked: !parentList.node.attrs.checked,
// });
// view.dispatch(tr);
// },
// },
// }),
// ];
// addNodeView() {
// return ReactNodeViewRenderer(TaskItemWrapper);
// },
addNodeView() {
return ({ node, HTMLAttributes, getPos, editor }) => {
const listItem = document.createElement('li');
const checkboxWrapper = document.createElement('span');
const content = document.createElement('div');
checkboxWrapper.contentEditable = 'false';
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value);
});
listItem.dataset.checked = node.attrs.checked;
listItem.append(checkboxWrapper, content);
Object.entries(HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value);
});
return {
dom: listItem,
contentDOM: content,
update: (updatedNode) => {
if (updatedNode.type !== this.type) {
return false;
}
listItem.dataset.checked = updatedNode.attrs.checked;
return true;
},
};
};
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// @ts-ignore
handleClick: (view, pos, event) => {
const state = view.state;
const schema = state.schema;
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
const position = state.doc.resolve(coordinates.pos);
const parentList = findParentNodeClosestToPos(position, function (node) {
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem;
});
if (!parentList) {
return;
}
const element = view.nodeDOM(parentList.pos) as HTMLLIElement;
if (element.tagName.toLowerCase() !== 'li') return;
const parentElement = element.parentElement;
const type = parentElement && parentElement.getAttribute('data-type');
if (!type || type.toLowerCase() !== 'tasklist') return;
const tr = state.tr;
const nextValue = !(element.getAttribute('data-checked') === 'true');
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
checked: nextValue,
});
view.dispatch(tr);
},
},
}),
];
},
});
export const TaskItem = CustomTaskItem.configure({ nested: true });

View File

@ -11,6 +11,8 @@ import { HorizontalRule } from '../extensions/horizontalRule';
import { Iframe } from '../extensions/iframe';
import { Mind } from '../extensions/mind';
import { Table } from '../extensions/table';
import { TaskList } from '../extensions/taskList';
import { TaskItem } from '../extensions/taskItem';
import { Katex } from '../extensions/katex';
import { DocumentReference } from '../extensions/documentReference';
import { DocumentChildren } from '../extensions/documentChildren';
@ -26,6 +28,8 @@ const OTHER_BUBBLE_MENU_TYPES = [
Iframe.name,
Mind.name,
Table.name,
TaskList.name,
TaskItem.name,
DocumentReference.name,
DocumentChildren.name,
Katex.name,

View File

@ -10,8 +10,6 @@ export const markdownToProsemirror = ({ schema, content, hasTitle }) => {
if (!html) return null;
console.log(html);
const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
body.append(document.createComment(content));

View File

@ -10,7 +10,7 @@ import { DocumentReference } from '../../../extensions/documentReference';
import { HardBreak } from '../../../extensions/hardBreak';
import { Heading } from '../../../extensions/heading';
import { HorizontalRule } from '../../../extensions/horizontalRule';
import { marks, HTMLMarks } from '../../../extensions/htmlMarks';
import { marks } from '../../../extensions/htmlMarks';
import { Iframe } from '../../../extensions/iframe';
import { Image } from '../../../extensions/image';
import { Italic } from '../../../extensions/italic';
@ -29,6 +29,7 @@ import { TableRow } from '../../../extensions/tableRow';
import { Text } from '../../../extensions/text';
import { TaskItem } from '../../../extensions/taskItem';
import { TaskList } from '../../../extensions/taskList';
import { TextStyle } from '../../../extensions/textStyle';
import { Title } from '../../../extensions/title';
import {
isPlainURL,
@ -66,6 +67,8 @@ const SerializerConfig = {
mixable: true,
expelEnclosingWhitespace: true,
},
// FIXME: 如何导出 style
[TextStyle.name]: { open: '', close: '', mixable: true, expelEnclosingWhitespace: true },
...marks.reduce(
(acc, { name, tag }) => ({
...acc,
@ -151,10 +154,9 @@ const SerializerConfig = {
*/
export const prosemirrorToMarkdown = ({ content }) => {
const serializer = new ProseMirrorMarkdownSerializer(SerializerConfig.nodes, SerializerConfig.marks);
console.log(content);
return serializer.serialize(content, {
const markdown = serializer.serialize(content, {
tightLists: true,
});
return markdown;
};

View File

@ -178,11 +178,31 @@
li {
display: flex;
align-items: center;
cursor: pointer;
> label {
flex: 0 0 auto;
user-select: none;
transform: translateY(2px);
> span {
position: relative;
display: block;
width: 16px;
height: 16px;
border: 1px solid var(--semi-color-border);
border-radius: 2px;
background-color: #fff;
&::after {
content: ' ';
position: absolute;
left: 4.071429px;
top: -0.357143px;
width: 6.714286px;
height: 12.142857px;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transition: all 0.1s cubic-bezier(0.71, -0.46, 0.88, 0.6), opacity 0.1s;
transform: rotate(45deg) scale(0);
opacity: 0;
}
}
> div {
@ -193,6 +213,15 @@
&[data-checked='true'] {
color: var(--semi-color-text-2);
> span {
background-color: var(--semi-color-primary);
&::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
}
> div {
text-decoration: line-through;
}

View File

@ -10,7 +10,6 @@ export class CreateDocumentDto {
@IsString({ message: '文档名称类型错误正确类型为String' })
@IsNotEmpty({ message: '文档名称不能为空' })
@MinLength(1, { message: '文档名称至少1个字符' })
@MaxLength(50, { message: '文档名称最多50个字符' })
@IsOptional()
readonly title?: string;

View File

@ -19,7 +19,7 @@ export class DocumentEntity {
@Column({ type: 'varchar', comment: '父文档 Id', default: null })
public parentDocumentId: string;
@Column({ type: 'varchar', length: 50, comment: '文档标题', default: '' })
@Column({ type: 'varchar', default: '未命名文档', comment: '文档标题' })
public title: string;
@Column({ type: 'text', comment: '文档内容' })

View File

@ -87,7 +87,6 @@ importers:
'@tiptap/extension-underline': ^2.0.0-beta.23
'@tiptap/react': ^2.0.0-beta.107
'@tiptap/suggestion': ^2.0.0-beta.90
'@traptitech/markdown-it-katex': ^3.5.0
'@types/node': 17.0.13
'@types/react': 17.0.38
axios: ^0.25.0
@ -102,11 +101,8 @@ importers:
markdown-it-anchor: ^8.4.1
markdown-it-container: ^3.0.0
markdown-it-emoji: ^2.0.0
markdown-it-footnote: ^3.0.3
markdown-it-sub: ^1.0.0
markdown-it-sup: ^1.0.0
markdown-it-task-lists: ^2.1.1
marked: ^4.0.12
next: 12.0.10
prosemirror-markdown: ^1.7.0
prosemirror-tables: ^1.1.1
@ -166,7 +162,6 @@ importers:
'@tiptap/extension-underline': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171
'@tiptap/react': 2.0.0-beta.107_a3fcdb91535fe17b69dfabaa94f3bb3d
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
'@traptitech/markdown-it-katex': 3.5.0
axios: 0.25.0
classnames: 2.3.1
copy-to-clipboard: 3.3.1
@ -179,11 +174,8 @@ importers:
markdown-it-anchor: 8.4.1_markdown-it@12.3.2
markdown-it-container: 3.0.0
markdown-it-emoji: 2.0.0
markdown-it-footnote: 3.0.3
markdown-it-sub: 1.0.0
markdown-it-sup: 1.0.0
markdown-it-task-lists: 2.1.1
marked: 4.0.12
next: 12.0.10_react-dom@17.0.2+react@17.0.2
prosemirror-markdown: 1.7.0
prosemirror-tables: 1.1.1
@ -1953,12 +1945,6 @@ packages:
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
/@traptitech/markdown-it-katex/3.5.0:
resolution: {integrity: sha512-7/GI3ETKJjrZD9+azn7WraDWo0ZQ6grtzR4I36qu7U0vOJMBtC+znX7UghdOScrgGnxqGvgWm07SYnlcCtdCvw==}
dependencies:
katex: 0.15.2
dev: false
/@tsconfig/node10/1.0.8:
resolution: {integrity: sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==}
dev: true
@ -6008,10 +5994,6 @@ packages:
resolution: {integrity: sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==}
dev: false
/markdown-it-footnote/3.0.3:
resolution: {integrity: sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w==}
dev: false
/markdown-it-sub/1.0.0:
resolution: {integrity: sha1-N1/WAm6ufdywEkl/ZBEZXqHjr+g=}
dev: false
@ -6020,10 +6002,6 @@ packages:
resolution: {integrity: sha1-y5yf+RpSVawI8/09YyhuFd8KH8M=}
dev: false
/markdown-it-task-lists/2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
dev: false
/markdown-it/12.3.2:
resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==}
hasBin: true
@ -6035,12 +6013,6 @@ packages:
uc.micro: 1.0.6
dev: false
/marked/4.0.12:
resolution: {integrity: sha512-hgibXWrEDNBWgGiK18j/4lkS6ihTe9sxtV4Q1OQppb/0zzyPSzoFANBa5MfsG/zgsWklmNnhm0XACZOH/0HBiQ==}
engines: {node: '>= 12'}
hasBin: true
dev: false
/mdurl/1.0.1:
resolution: {integrity: sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=}
dev: false