mirror of
https://github.com/docmost/docmost.git
synced 2025-11-19 20:21:09 +10:00
switch to nx monorepo
This commit is contained in:
@ -0,0 +1,35 @@
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
137
apps/client/src/features/editor/extensions/comment/comment.ts
Normal file
137
apps/client/src/features/editor/extensions/comment/comment.ts
Normal file
@ -0,0 +1,137 @@
|
||||
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()];
|
||||
},
|
||||
|
||||
},
|
||||
);
|
||||
223
apps/client/src/features/editor/extensions/drag-handle.ts
Normal file
223
apps/client/src/features/editor/extensions/drag-handle.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state';
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard as serializeForClipboard, EditorView } from '@tiptap/pm/view';
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
}
|
||||
|
||||
function removeNode(node) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
|
||||
function absoluteRect(node) {
|
||||
const data = node.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: HTMLElement) =>
|
||||
elem.parentElement?.matches?.('.ProseMirror') ||
|
||||
elem.matches(
|
||||
[
|
||||
'li',
|
||||
'p:not(:first-child)',
|
||||
'pre',
|
||||
'blockquote',
|
||||
'h1, h2, h3',
|
||||
'[data-type=horizontalRule]',
|
||||
].join(', '),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function nodePosAtDOM(node: Element, view: EditorView) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 1,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function DragHandle(options: DragHandleOptions) {
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view);
|
||||
if (!nodePos) return;
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(
|
||||
NodeSelection.create(view.state.doc, nodePos),
|
||||
),
|
||||
);
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||
event.dataTransfer.setData('text/plain', text);
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent, view: EditorView) {
|
||||
view.focus();
|
||||
view.dom.classList.remove('dragging');
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view);
|
||||
if (!nodePos) return;
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(
|
||||
NodeSelection.create(view.state.doc, nodePos),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return new Plugin({
|
||||
view: (view) => {
|
||||
dragHandleElement = document.createElement('div');
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = '';
|
||||
dragHandleElement.classList.add('drag-handle');
|
||||
dragHandleElement.addEventListener('dragstart', (e) => {
|
||||
handleDragStart(e, view);
|
||||
});
|
||||
dragHandleElement.addEventListener('click', (e) => {
|
||||
handleClick(e, view);
|
||||
});
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
dragHandleElement?.remove?.();
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (
|
||||
node.matches('ul:not([data-type=taskList]) li, ol li')
|
||||
) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add('dragging');
|
||||
},
|
||||
drop: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface DragAndDropOptions {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const DragAndDrop = Extension.create<DragAndDropOptions>({
|
||||
name: 'dragAndDrop',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandle({
|
||||
dragHandleWidth: 24,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default DragAndDrop;
|
||||
67
apps/client/src/features/editor/extensions/extensions.ts
Normal file
67
apps/client/src/features/editor/extensions/extensions.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
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 { 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';
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
Comment.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'comment-mark',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider) => any[];
|
||||
|
||||
export const collabExtensions: CollabExtensions = (provider) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
42
apps/client/src/features/editor/extensions/slash-command.ts
Normal file
42
apps/client/src/features/editor/extensions/slash-command.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
|
||||
import renderItems from '@/features/editor/components/slash-menu/render-items';
|
||||
import getSuggestionItems from '@/features/editor/components/slash-menu/menu-items';
|
||||
|
||||
export const slashMenuPluginKey = new PluginKey('slash-command');
|
||||
|
||||
// @ts-ignore
|
||||
const Command = Extension.create({
|
||||
name: 'slash-command',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
command: ({ editor, range, props }) => {
|
||||
props.command({ editor, range, props });
|
||||
},
|
||||
} as Partial<SuggestionOptions>,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
pluginKey: slashMenuPluginKey,
|
||||
...this.options.suggestion,
|
||||
editor: this.editor,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const SlashCommand = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default SlashCommand;
|
||||
69
apps/client/src/features/editor/extensions/trailing-node.ts
Normal file
69
apps/client/src/features/editor/extensions/trailing-node.ts
Normal file
@ -0,0 +1,69 @@
|
||||
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 })
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user