* add new tiptap editor extension monorepo package

* move tiptap packages to main package.json
* add tiptap extensions schema to collaborative backend
* add basic README
This commit is contained in:
Philipinho
2024-01-14 23:05:41 +01:00
parent e5758f7ece
commit 9a8b605f70
16 changed files with 469 additions and 314 deletions

View File

@ -9,7 +9,6 @@
"preview": "vite preview"
},
"dependencies": {
"@hocuspocus/provider": "^2.8.1",
"@mantine/core": "^7.2.2",
"@mantine/form": "^7.2.2",
"@mantine/hooks": "^7.2.2",
@ -18,31 +17,6 @@
"@mantine/spotlight": "^7.2.2",
"@tabler/icons-react": "^2.42.0",
"@tanstack/react-query": "^5.8.6",
"@tiptap/extension-code-block": "^2.1.12",
"@tiptap/extension-collaboration": "^2.1.12",
"@tiptap/extension-collaboration-cursor": "^2.1.12",
"@tiptap/extension-color": "^2.1.12",
"@tiptap/extension-document": "^2.1.12",
"@tiptap/extension-heading": "^2.1.12",
"@tiptap/extension-highlight": "^2.1.12",
"@tiptap/extension-link": "^2.1.12",
"@tiptap/extension-list-item": "^2.1.12",
"@tiptap/extension-list-keymap": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-placeholder": "^2.1.12",
"@tiptap/extension-subscript": "^2.1.12",
"@tiptap/extension-superscript": "^2.1.12",
"@tiptap/extension-task-item": "^2.1.12",
"@tiptap/extension-task-list": "^2.1.12",
"@tiptap/extension-text": "^2.1.12",
"@tiptap/extension-text-align": "^2.1.12",
"@tiptap/extension-text-style": "^2.1.12",
"@tiptap/extension-typography": "^2.1.12",
"@tiptap/extension-underline": "^2.1.12",
"@tiptap/pm": "^2.1.12",
"@tiptap/react": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
@ -56,8 +30,6 @@
"socket.io-client": "^4.7.2",
"tippy.js": "^6.3.7",
"uuid": "^9.0.1",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.10",
"zod": "^3.22.4"
},
"devDependencies": {

View File

@ -1,35 +0,0 @@
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { commentDecorationMetaKey, commentMarkClass } from '@/features/editor/extensions/comment/comment';
export function commentDecoration(): Plugin {
const commentDecorationPlugin = new PluginKey('commentDecoration');
return new Plugin({
key: commentDecorationPlugin,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, oldSet) {
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
if (decorationMeta) {
const { from, to } = tr.selection;
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
return DecorationSet.create(tr.doc, [decoration]);
} else if (decorationMeta === false) {
return DecorationSet.empty;
}
return oldSet.map(tr.mapping, tr.doc);
},
},
props: {
decorations: (state) => {
return commentDecorationPlugin.getState(state);
},
},
});
}

View File

@ -1,137 +0,0 @@
import { Mark, mergeAttributes } from '@tiptap/core';
import { commentDecoration } from '@/features/editor/extensions/comment/comment-decoration';
export interface ICommentOptions {
HTMLAttributes: Record<string, any>,
}
export interface ICommentStorage {
activeCommentId: string | null;
}
export const commentMarkClass = 'comment-mark';
export const commentDecorationMetaKey = 'decorateComment';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
comment: {
setCommentDecoration: () => ReturnType,
unsetCommentDecoration: () => ReturnType,
setComment: (commentId: string) => ReturnType,
unsetComment: (commentId: string) => ReturnType,
};
}
}
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
name: 'comment',
exitable: true,
inclusive: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addStorage() {
return {
activeCommentId: null,
};
},
addAttributes() {
return {
commentId: {
default: null,
parseHTML: element => element.getAttribute('data-comment-id'),
renderHTML: (attributes) => {
if (!attributes.commentId) return;
return {
'data-comment-id': attributes.commentId,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-comment-id]',
getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null,
},
];
},
addCommands() {
return {
setCommentDecoration: () => ({ tr, dispatch }) => {
tr.setMeta(commentDecorationMetaKey, true);
if (dispatch) dispatch(tr);
return true;
},
unsetCommentDecoration: () => ({ tr, dispatch }) => {
tr.setMeta(commentDecorationMetaKey, false);
if (dispatch) dispatch(tr);
return true;
},
setComment: (commentId) => ({ commands }) => {
if (!commentId) return false;
return commands.setMark(this.name, { commentId });
},
unsetComment:
(commentId) =>
({ tr, dispatch }) => {
if (!commentId) return false;
tr.doc.descendants((node, pos) => {
const from = pos;
const to = pos + node.nodeSize;
const commentMark = node.marks.find(mark =>
mark.type.name === this.name && mark.attrs.commentId === commentId);
if (commentMark) {
tr = tr.removeMark(from, to, commentMark);
}
});
return dispatch?.(tr);
},
};
},
renderHTML({ HTMLAttributes }) {
const commentId = HTMLAttributes?.['data-comment-id'] || null;
const elem = document.createElement('span');
Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
).forEach(([attr, val]) => elem.setAttribute(attr, val));
elem.addEventListener('click', (e) => {
const selection = document.getSelection();
if (selection.type === 'Range') return;
this.storage.activeCommentId = commentId;
const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', {
bubbles: true,
detail: { commentId },
});
elem.dispatchEvent(commentEventClick);
});
return elem;
},
// @ts-ignore
addProseMirrorPlugins(): Plugin[] {
// @ts-ignore
return [commentDecoration()];
},
},
);

View File

@ -9,15 +9,14 @@ import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography';
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
import DragAndDrop from '@/features/editor/extensions/drag-handle';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import SlashCommand from '@/features/editor/extensions/slash-command';
import { Collaboration } from '@tiptap/extension-collaboration';
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
import { Comment } from '@/features/editor/extensions/comment/comment';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { Comment, TrailingNode } from '@docmost/editor-ext';
export const mainExtensions = [
StarterKit.configure({

View File

@ -1,69 +0,0 @@
import { Extension } from '@tiptap/core'
import { PluginKey, Plugin } from '@tiptap/pm/state';
export interface TrailingNodeExtensionOptions {
node: string,
notAfter: string[],
}
function nodeEqualsType({ types, node }: { types: any, node: any }) {
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
}
// @ts-ignore
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
name: 'trailingNode',
addOptions() {
return {
node: 'paragraph',
notAfter: [
'paragraph',
],
};
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name)
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter(node => this.options.notAfter.includes(node.name))
return [
new Plugin({
key: plugin,
appendTransaction: (_, __, state) => {
const { doc, tr, schema } = state;
const shouldInsertNodeAtEnd = plugin.getState(state);
const endPosition = doc.content.size;
const type = schema.nodes[this.options.node]
if (!shouldInsertNodeAtEnd) {
return;
}
return tr.insert(endPosition, type.create());
},
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value
}
const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
},
}),
]
}
})

View File

@ -19,8 +19,8 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs -d src/database/typeorm.config.ts",
"migration:generate": "pnpm run typeorm migration:generate ./src/database/migrations/$npm_config_name",
"migration:create": "typeorm-ts-node-commonjs migration:create ./src/database/migrations/$npm_config_name",
"migration:generate": "cd ./src/database/migrations/ && pnpm run typeorm migration:generate",
"migration:create": "cd ./src/database/migrations/ && typeorm-ts-node-commonjs migration:create",
"migration:run": "pnpm run typeorm migration:run",
"migration:revert": "pnpm run typeorm migration:revert",
"migration:show": "pnpm run typeorm migration:show"
@ -30,8 +30,6 @@
"@aws-sdk/s3-request-presigner": "^3.456.0",
"@fastify/multipart": "^8.1.0",
"@fastify/static": "^6.12.0",
"@hocuspocus/server": "^2.8.1",
"@hocuspocus/transformer": "^2.8.1",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",

View File

@ -7,6 +7,19 @@ import * as Y from 'yjs';
import { PageService } from '../../core/page/services/page.service';
import { Injectable } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Link } from '@tiptap/extension-link';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { TrailingNode, Comment } from '@docmost/editor-ext';
@Injectable()
export class PersistenceExtension implements Extension {
@ -42,7 +55,22 @@ export class PersistenceExtension implements Extension {
if (page.content) {
console.log('converting json to ydoc');
const ydoc = TiptapTransformer.toYdoc(page.content, 'default');
const ydoc = TiptapTransformer.toYdoc(page.content, 'default', [
StarterKit,
Comment,
TextAlign,
TaskList,
TaskItem,
Underline,
Link,
Superscript,
SubScript,
Highlight,
Typography,
TrailingNode,
TextStyle,
Color,
]);
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}