mirror of
https://github.com/docmost/docmost.git
synced 2025-11-20 09:21:10 +10:00
work on editor
This commit is contained in:
223
client/src/features/editor/extensions/drag-handle.ts
Normal file
223
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;
|
||||
42
client/src/features/editor/extensions/slash-command.ts
Normal file
42
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
client/src/features/editor/extensions/trailing-node.ts
Normal file
69
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