Merge pull request #121 from fantasticit/feat/drag

Feat/drag
This commit is contained in:
fantasticit 2022-07-09 10:17:24 +08:00 committed by GitHub
commit 36f74a408a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 299 additions and 101 deletions

View File

@ -0,0 +1,146 @@
import { Extension } from '@tiptap/core';
import { Plugin } from 'prosemirror-state';
import { NodeSelection } from 'prosemirror-state';
import { __serializeForClipboard } from 'prosemirror-view';
function createRect(rect) {
if (rect == null) {
return null;
}
const newRect = {
left: rect.left + document.body.scrollLeft,
top: rect.top + document.body.scrollTop,
width: rect.width,
height: rect.height,
bottom: 0,
right: 0,
};
newRect.bottom = newRect.top + newRect.height;
newRect.right = newRect.left + newRect.width;
return newRect;
}
function absoluteRect(element) {
return createRect(element.getBoundingClientRect());
}
export const Dragable = Extension.create({
name: 'dragable',
addProseMirrorPlugins() {
let dropElement;
let currentNode;
let editorView;
const WIDTH = 24;
function drag(e) {
if (!currentNode || currentNode.nodeType !== 1) return;
let pos = null;
const desc = editorView.docView.nearestDesc(currentNode, true);
if (!(!desc || desc === editorView.docView)) {
pos = desc.posBefore;
}
if (!pos) return;
editorView.dispatch(editorView.state.tr.setSelection(NodeSelection.create(editorView.state.doc, pos)));
const slice = editorView.state.selection.content();
const { dom, text } = __serializeForClipboard(editorView, slice);
e.dataTransfer.clearData();
e.dataTransfer.setData('text/html', dom.innerHTML);
e.dataTransfer.setData('text/plain', text);
editorView.dragging = { slice, move: true };
}
return [
new Plugin({
view(view) {
if (view.editable) {
editorView = view;
dropElement = document.createElement('div');
dropElement.setAttribute('draggable', 'true');
dropElement.className = 'drag-handler';
dropElement.addEventListener('dragstart', drag);
view.dom.parentElement.appendChild(dropElement);
}
return {
update(view) {
editorView = view;
},
destroy() {
if (dropElement && dropElement.parentNode) {
dropElement.removeEventListener('dragstart', drag);
dropElement.parentNode.removeChild(dropElement);
}
},
};
},
props: {
handleDOMEvents: {
drop() {
if (!dropElement) return;
dropElement.style.opacity = 0;
setTimeout(() => {
const node = document.querySelector('.ProseMirror-hideselection');
if (node) {
node.classList.remove('ProseMirror-hideselection');
}
}, 50);
},
mousemove(view, event) {
if (!dropElement) return;
const coords = { left: event.clientX, top: event.clientY };
const pos = view.posAtCoords(coords);
if (!pos) {
dropElement.style.opacity = 0;
return;
}
let node = view.domAtPos(pos.pos);
node = node.node;
while (node && node.parentNode) {
if (node.parentNode?.classList?.contains?.('ProseMirror')) {
break;
}
node = node.parentNode;
}
if (!node || !node.getBoundingClientRect) {
dropElement.style.opacity = 0;
return;
}
if (node?.classList?.contains('node-title') || node?.classList?.contains('node-table')) {
dropElement.style.opacity = 0;
return;
}
currentNode = node;
const rect = absoluteRect(node);
const win = node.ownerDocument.defaultView;
rect.top += win.pageYOffset;
rect.left += win.pageXOffset;
rect.width = WIDTH + 'px';
dropElement.style.left = rect.left - WIDTH + 'px';
dropElement.style.top = rect.top + 6 + 'px';
dropElement.style.opacity = 1;
},
mouseleave() {
if (!dropElement || currentNode) return;
dropElement.style.opacity = 0;
},
},
},
}),
];
},
});

View File

@ -1,12 +1,3 @@
import TitapParagraph from '@tiptap/extension-paragraph'; import TitapParagraph from '@tiptap/extension-paragraph';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { ParagraphWrapper } from '../wrappers/paragraph'; export const Paragraph = TitapParagraph.extend({});
export const Paragraph = TitapParagraph.extend({
draggable: true,
addNodeView() {
return ReactNodeViewRenderer(ParagraphWrapper);
},
});

View File

@ -1,45 +1,135 @@
import { wrappingInputRule } from '@tiptap/core'; import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core';
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
import { Plugin } from 'prosemirror-state'; export interface TaskItemOptions {
import { findParentNodeClosestToPos } from 'prosemirror-utils'; nested: boolean;
import { PARSE_HTML_PRIORITY_HIGHEST } from 'tiptap/core/constants'; HTMLAttributes: Record<string, any>;
}
export const inputRegex = /^\s*(\[([ |x])\])\s$/;
export const TaskItem = Node.create<TaskItemOptions>({
name: 'taskItem',
addOptions() {
return {
nested: false,
HTMLAttributes: {},
};
},
content() {
return this.options.nested ? 'paragraph block*' : 'paragraph+';
},
defining: true,
addAttributes() {
return {
checked: {
default: false,
keepOnSplit: false,
parseHTML: (element) => element.getAttribute('data-checked') === 'true',
renderHTML: (attributes) => ({
'data-checked': attributes.checked,
}),
},
};
},
const CustomTaskItem = BuiltInTaskItem.extend({
parseHTML() { parseHTML() {
return [ return [
{ {
tag: 'li.task-list-item', tag: `li.task-list-item`,
priority: PARSE_HTML_PRIORITY_HIGHEST, priority: 51,
}, },
]; ];
}, },
addInputRules() { renderHTML({ node, HTMLAttributes }) {
return [ return [
...this.parent(), 'li',
wrappingInputRule({ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }),
find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/, [
type: this.type, 'label',
getAttributes: (match) => ({ [
checked: 'xX'.includes(match[match.length - 1]), 'input',
}), {
}), type: 'checkbox',
checked: node.attrs.checked ? 'checked' : null,
},
],
['span'],
],
['div', 0],
]; ];
}, },
addKeyboardShortcuts() {
const shortcuts = {
'Enter': () => this.editor.commands.splitListItem(this.name),
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
};
if (!this.options.nested) {
return shortcuts;
}
return {
...shortcuts,
Tab: () => this.editor.commands.sinkListItem(this.name),
};
},
addNodeView() { addNodeView() {
return ({ node, HTMLAttributes, getPos, editor }) => { return ({ node, HTMLAttributes, getPos, editor }) => {
const listItem = document.createElement('li'); const listItem = document.createElement('li');
const checkboxWrapper = document.createElement('span'); const checkboxWrapper = document.createElement('label');
const checkboxStyler = document.createElement('span');
const checkbox = document.createElement('input');
const content = document.createElement('div'); const content = document.createElement('div');
checkboxWrapper.contentEditable = 'false'; checkboxWrapper.contentEditable = 'false';
checkbox.type = 'checkbox';
checkbox.addEventListener('change', (event) => {
// if the editor isnt editable
// we have to undo the latest change
// if (!editor.isEditable) {
// checkbox.checked = !checkbox.checked;
// return;
// }
const { checked } = event.target as any;
if (typeof getPos === 'function') {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.command(({ tr }) => {
const position = getPos();
const currentNode = tr.doc.nodeAt(position);
tr.setNodeMarkup(position, undefined, {
...currentNode?.attrs,
checked,
});
return true;
})
.run();
}
});
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value); listItem.setAttribute(key, value);
}); });
listItem.dataset.checked = node.attrs.checked; listItem.dataset.checked = node.attrs.checked;
if (node.attrs.checked) {
checkbox.setAttribute('checked', 'checked');
}
checkboxWrapper.append(checkbox, checkboxStyler);
listItem.append(checkboxWrapper, content); listItem.append(checkboxWrapper, content);
Object.entries(HTMLAttributes).forEach(([key, value]) => { Object.entries(HTMLAttributes).forEach(([key, value]) => {
@ -55,61 +145,34 @@ const CustomTaskItem = BuiltInTaskItem.extend({
} }
listItem.dataset.checked = updatedNode.attrs.checked; listItem.dataset.checked = updatedNode.attrs.checked;
if (updatedNode.attrs.checked) {
checkbox.setAttribute('checked', 'checked');
} else {
checkbox.removeAttribute('checked');
}
return true; return true;
}, },
}; };
}; };
}, },
addProseMirrorPlugins() { addInputRules() {
const extensionThis = this;
return [ return [
new Plugin({ wrappingInputRule({
props: { find: inputRegex,
handleClick: (view, pos, event) => { type: this.type,
const state = view.state; getAttributes: (match) => ({
const schema = state.schema; checked: match[match.length - 1] === 'x',
}),
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); }),
const position = state.doc.resolve(coordinates.pos); wrappingInputRule({
const parentList = findParentNodeClosestToPos(position, function (node) { find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/,
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem; type: this.type,
}); getAttributes: (match) => ({
if (!parentList) { checked: 'xX'.includes(match[match.length - 1]),
return false; }),
}
const element = view.nodeDOM(parentList.pos) as HTMLLIElement;
if (element.tagName.toLowerCase() !== 'li') return false;
// 编辑模式:仅当点击 SPAN 时进行状态修改
if (view.editable) {
const target = event.target as HTMLElement;
if (target.tagName.toLowerCase() !== 'span') return false;
} else {
// 非编辑模式,仅支持配置 taskItemClickable 可点击
// @ts-ignore
if (!extensionThis.editor.options.editorProps.taskItemClickable) {
return;
}
}
const parentElement = element.parentElement;
const type = parentElement && parentElement.getAttribute('data-type');
if (!type || type.toLowerCase() !== 'tasklist') return false;
const tr = state.tr;
const nextValue = !(element.getAttribute('data-checked') === 'true');
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
checked: nextValue,
});
view.dispatch(tr);
return true;
},
},
}), }),
]; ];
}, },
}); });
export const TaskItem = CustomTaskItem.configure({ nested: true });

View File

@ -70,6 +70,7 @@
} }
p { p {
margin-top: 0.75rem;
margin-bottom: 0; margin-bottom: 0;
font-size: 1em; font-size: 1em;
font-weight: normal; font-weight: normal;

View File

@ -0,0 +1,11 @@
.drag-handler {
position: fixed;
width: 16px;
height: 16px;
cursor: grab;
opacity: 1;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 16"><path fill-opacity="0.2" d="M4 14c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM2 6C.9 6 0 6.9 0 8s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6C.9 0 0 .9 0 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" /></svg>');
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}

View File

@ -17,3 +17,4 @@
@import './table.scss'; @import './table.scss';
@import './title.scss'; @import './title.scss';
@import './kityminder.scss'; @import './kityminder.scss';
@import './drag.scss';

View File

@ -52,7 +52,7 @@
padding-left: 16px; padding-left: 16px;
cursor: pointer; cursor: pointer;
> span { > label {
position: absolute; position: absolute;
top: 6px; top: 6px;
left: 0; left: 0;
@ -63,6 +63,10 @@
border: 1px solid var(--semi-color-border); border: 1px solid var(--semi-color-border);
border-radius: 2px; border-radius: 2px;
> input {
display: none;
}
&::after { &::after {
position: absolute; position: absolute;
top: -0.357143px; top: -0.357143px;
@ -87,7 +91,7 @@
&[data-checked='true'] { &[data-checked='true'] {
color: var(--semi-color-text-2); color: var(--semi-color-text-2);
> span { > label {
background-color: var(--semi-color-primary); background-color: var(--semi-color-primary);
&::after { &::after {

View File

@ -5,9 +5,9 @@
.dragHandle { .dragHandle {
position: absolute; position: absolute;
top: 0.3rem; top: 0.3rem;
left: -1.5rem; left: -24px;
width: 1rem; width: 16px;
height: 1rem; height: 16px;
cursor: grab; cursor: grab;
opacity: 0; opacity: 0;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 16"><path fill-opacity="0.2" d="M4 14c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM2 6C.9 6 0 6.9 0 8s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6C.9 0 0 .9 0 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" /></svg>'); background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 16"><path fill-opacity="0.2" d="M4 14c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM2 6C.9 6 0 6.9 0 8s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6C.9 0 0 .9 0 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" /></svg>');

View File

@ -1,3 +0,0 @@
.paragraph {
margin-top: 0.75em;
}

View File

@ -1,18 +0,0 @@
import { NodeViewContent } from '@tiptap/react';
import { useCallback } from 'react';
import { DragableWrapper } from 'tiptap/core/wrappers/dragable';
import styles from './index.module.scss';
export const ParagraphWrapper = ({ editor }) => {
const prevent = useCallback((e) => {
e.prevntDefault();
return false;
}, []);
return (
<DragableWrapper editor={editor} className={styles.paragraph}>
<NodeViewContent draggable="false" onDragStart={prevent} />
</DragableWrapper>
);
};

View File

@ -16,6 +16,7 @@ import { Countdown } from 'tiptap/core/extensions/countdown';
import { Document } from 'tiptap/core/extensions/document'; import { Document } from 'tiptap/core/extensions/document';
import { DocumentChildren } from 'tiptap/core/extensions/document-children'; import { DocumentChildren } from 'tiptap/core/extensions/document-children';
import { DocumentReference } from 'tiptap/core/extensions/document-reference'; import { DocumentReference } from 'tiptap/core/extensions/document-reference';
import { Dragable } from 'tiptap/core/extensions/dragable';
import { Dropcursor } from 'tiptap/core/extensions/dropcursor'; import { Dropcursor } from 'tiptap/core/extensions/dropcursor';
import { Emoji } from 'tiptap/core/extensions/emoji'; import { Emoji } from 'tiptap/core/extensions/emoji';
import { EventEmitter } from 'tiptap/core/extensions/event-emitter'; import { EventEmitter } from 'tiptap/core/extensions/event-emitter';
@ -98,6 +99,7 @@ export const CollaborationKit = [
CodeBlock, CodeBlock,
Color, Color,
ColorHighlighter, ColorHighlighter,
Dragable,
Dropcursor, Dropcursor,
EventEmitter, EventEmitter,
Focus, Focus,