tiptap: improve drag

This commit is contained in:
fantasticit 2022-08-04 16:51:03 +08:00
parent 8ad3002368
commit bf4a3ec576
19 changed files with 569 additions and 360 deletions

View File

@ -0,0 +1,173 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, Selection } from 'prosemirror-state';
import { NodeSelection } from 'prosemirror-state';
import { __serializeForClipboard, EditorView } from 'prosemirror-view';
import { ActiveNode, removePossibleTable, selectRootNodeByDom } from 'tiptap/prose-utils';
export const DragablePluginKey = new PluginKey('dragable');
export const Dragable = Extension.create({
name: 'dragable',
addProseMirrorPlugins() {
let editorView: EditorView;
let dragHandleDOM: HTMLElement;
let activeNode: ActiveNode;
let activeSelection: Selection;
let dragging = false;
const createDragHandleDOM = () => {
const dom = document.createElement('div');
dom.draggable = true;
dom.setAttribute('data-drag-handle', 'true');
return dom;
};
const showDragHandleDOM = () => {
dragHandleDOM.classList.add('show');
dragHandleDOM.classList.remove('hide');
};
const hideDragHandleDOM = () => {
dragHandleDOM.classList.remove('show');
dragHandleDOM.classList.add('hide');
};
const renderDragHandleDOM = (view: EditorView, el: HTMLElement) => {
const root = view.dom.parentElement;
if (!root) return;
const targetNodeRect = (<HTMLElement>el).getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
const handleRect = dragHandleDOM.getBoundingClientRect();
const left = targetNodeRect.left - rootRect.left - handleRect.width - handleRect.width / 2;
const top = targetNodeRect.top - rootRect.top + handleRect.height / 2 + root.scrollTop;
dragHandleDOM.style.left = `${left}px`;
dragHandleDOM.style.top = `${top}px`;
showDragHandleDOM();
};
const handleMouseDown = () => {
if (!activeNode) return null;
if (NodeSelection.isSelectable(activeNode.node)) {
const nodeSelection = NodeSelection.create(editorView.state.doc, activeNode.$pos.pos - activeNode.offset);
editorView.dispatch(editorView.state.tr.setSelection(nodeSelection));
editorView.focus();
activeSelection = nodeSelection;
return nodeSelection;
}
return null;
};
const handleMouseUp = () => {
if (!dragging) return;
dragging = false;
activeSelection = null;
};
const handleDragStart = (event) => {
dragging = true;
if (event.dataTransfer && activeSelection) {
const brokenClipboardAPI = false;
const slice = activeSelection.content();
event.dataTransfer.effectAllowed = 'copyMove';
const { dom, text } = __serializeForClipboard(editorView, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData(brokenClipboardAPI ? 'Text' : 'text/html', dom.innerHTML);
if (!brokenClipboardAPI) event.dataTransfer.setData('text/plain', text);
editorView.dragging = {
slice,
move: true,
};
}
};
return [
new Plugin({
key: DragablePluginKey,
view: (view) => {
if (view.editable) {
dragHandleDOM = createDragHandleDOM();
dragHandleDOM.addEventListener('mousedown', handleMouseDown);
dragHandleDOM.addEventListener('mouseup', handleMouseUp);
dragHandleDOM.addEventListener('dragstart', handleDragStart);
view.dom.parentNode?.appendChild(dragHandleDOM);
}
return {
update(view) {
editorView = view;
},
destroy: () => {
if (!dragHandleDOM) return;
dragHandleDOM.remove();
},
};
},
props: {
handleDOMEvents: {
drop: (view, event: DragEvent) => {
if (!view.editable || !dragHandleDOM) return false;
if (dragging) {
const tr = removePossibleTable(view, event);
dragging = false;
if (tr) {
view.dispatch(tr);
event.preventDefault();
return true;
}
}
return false;
},
mousemove: (view, event) => {
if (!view.editable || !dragHandleDOM) return false;
const dom = event.target;
if (!(dom instanceof Element)) {
if (dragging) return false;
hideDragHandleDOM();
return false;
}
const result = selectRootNodeByDom(dom, view);
activeNode = result;
if (!result) {
if (dragging) return false;
hideDragHandleDOM();
return false;
}
if (result.node.type.name === 'title') {
if (dragging) return false;
hideDragHandleDOM();
return false;
}
renderDragHandleDOM(view, result.el);
return false;
},
keydown: () => {
if (!editorView.editable || !dragHandleDOM) return false;
dragHandleDOM.classList.remove('show');
hideDragHandleDOM();
return false;
},
},
},
}),
];
},
});

View File

@ -3,20 +3,5 @@ import TitapParagraph from '@tiptap/extension-paragraph';
export const Paragraph = TitapParagraph.extend({
draggable: true,
renderHTML({ HTMLAttributes }) {
return [
'p',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
[
'div',
{
'contentEditable': 'false',
'draggable': 'true',
'data-drag-handle': 'true',
},
],
['div', 0],
];
},
selectable: true,
});

View File

@ -1,91 +1,26 @@
/* stylelint-disable */
.ProseMirror {
&.is-editable {
[data-drag-handle] {
position: relative;
display: inline;
opacity: 0;
transition: opacity 0.3s ease-out;
z-index: 100;
&:hover {
opacity: 1 !important;
}
&::before {
content: '';
position: absolute;
left: -24px;
top: 2px;
z-index: 100;
display: inline;
width: 16px;
height: 16px;
text-align: center;
margin-left: auto;
cursor: move;
opacity: 0;
transition: opacity 0.3s ease-out;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='3' y='1' width='3' height='3' rx='1.5' fill='%23111'/%3E%3Crect x='10' y='1' width='3' height='3' rx='1.5' fill='%23111'/%3E%3Crect x='3' y='6' width='3' height='3' rx='1.5' fill='%23111'/%3E%3Crect x='10' y='6' width='3' height='3' rx='1.5' fill='%23111'/%3E%3Crect x='3' y='11' width='3' height='3' rx='1.5' fill='%23111'/%3E%3Crect x='10' y='11' width='3' height='3' rx='1.5' fill='%23111'/%3E%3C/svg%3E");
background-size: contain;
background-position: center 0;
background-repeat: no-repeat;
filter: var(--invert-filter);
}
}
p {
[data-drag-handle] {
&::before {
top: 6px;
}
}
&.show {
opacity: 0.3;
&:hover {
[data-drag-handle] {
opacity: 0.3;
}
}
}
ul {
li {
[data-drag-handle] {
&::before {
left: -36px;
}
}
}
&[data-type='taskList'] {
li {
[data-drag-handle] {
&::before {
left: -46px;
}
}
}
}
}
ol {
li {
[data-drag-handle] {
&::before {
left: -36px;
}
}
opacity: 1;
}
}
.drag-container {
position: relative;
&:hover {
[data-drag-handle] {
opacity: 0.3;
}
}
.drag-content {
width: 100%;
}
}
&.hide {
opacity: 0;
}
}

View File

@ -1,5 +1,9 @@
/* stylelint-disable */
.ProseMirror {
p.selected-node {
outline: 1px solid var(--node-selected-border-color);
}
hr.selected-node {
&::after {
background-color: var(--node-selected-border-color);

View File

@ -154,10 +154,5 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
}
})();
return (
<NodeViewWrapper className={'drag-container'}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>{content}</div>
</NodeViewWrapper>
);
return <NodeViewWrapper>{content}</NodeViewWrapper>;
};

View File

@ -25,9 +25,7 @@ export const CalloutWrapper = ({ editor, node, updateAttributes }) => {
);
return (
<NodeViewWrapper id="js-callout-container" className={cls('drag-container', styles.wrap)}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<NodeViewWrapper id="js-callout-container" className={cls(styles.wrap)}>
<div
className={cls(styles.innerWrap, 'render-wrapper')}
style={{
@ -48,7 +46,6 @@ export const CalloutWrapper = ({ editor, node, updateAttributes }) => {
}}
/>
</div>
</div>
</NodeViewWrapper>
);
};

View File

@ -4,8 +4,6 @@ import { NodeViewContent, NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames';
import { copy } from 'helpers/copy';
import React, { useRef } from 'react';
import { CodeBlock } from 'tiptap/core/extensions/code-block';
import { DragableWrapper } from 'tiptap/core/wrappers/dragable';
import styles from './index.module.scss';
@ -16,9 +14,7 @@ export const CodeBlockWrapper = ({ editor, node: { attrs }, updateAttributes, ex
const $container = useRef<HTMLPreElement>();
return (
<NodeViewWrapper className={cls('drag-container', styles.wrap, !isPrint && styles.maxHeight, 'render-wrapper')}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<NodeViewWrapper className={cls(styles.wrap, !isPrint && styles.maxHeight, 'render-wrapper')}>
<div className={styles.handleWrap}>
<Select
size="small"
@ -48,7 +44,6 @@ export const CodeBlockWrapper = ({ editor, node: { attrs }, updateAttributes, ex
<pre ref={$container}>
<NodeViewContent as="code" />
</pre>
</div>
</NodeViewWrapper>
);
};

View File

@ -32,14 +32,11 @@ export const CountdownWrapper = ({ editor, node }) => {
const { title, date } = node.attrs;
return (
<NodeViewWrapper className={'drag-container'}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<NodeViewWrapper>
<div className={cls(styles.wrap, 'render-wrapper')}>
<Text>{title}</Text>
<ReactCountdown date={date} renderer={renderer}></ReactCountdown>
</div>
</div>
</NodeViewWrapper>
);
};

View File

@ -35,9 +35,10 @@ export const DocumentChildrenWrapper = ({ editor, node, updateAttributes }) => {
}, [node.attrs, wikiId, documentId, updateAttributes]);
return (
<NodeViewWrapper as="div" className={cls('drag-container', 'render-wrapper')}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={cls('drag-content', styles.wrap, isEditable && styles.isEditable, 'documentChildren')}>
<NodeViewWrapper
as="div"
className={cls('render-wrapper', styles.wrap, isEditable && styles.isEditable, 'documentChildren')}
>
<div>
<Text type="tertiary"></Text>
</div>
@ -76,7 +77,6 @@ export const DocumentChildrenWrapper = ({ editor, node, updateAttributes }) => {
) : (
<Text type="tertiary">使</Text>
)}
</div>
</NodeViewWrapper>
);
};

View File

@ -50,9 +50,8 @@ export const DocumentReferenceWrapper = ({ editor, node, updateAttributes }) =>
}, [organizationId, wikiId, documentId, isEditable, isShare, title]);
return (
<NodeViewWrapper as="div" className={cls('drag-container', styles.wrap, isEditable && 'render-wrapper')}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>{content}</div>
<NodeViewWrapper as="div" className={cls(styles.wrap, isEditable && 'render-wrapper')}>
{content}
</NodeViewWrapper>
);
};

View File

@ -95,9 +95,7 @@ export const FlowWrapper = ({ editor, node, updateAttributes }) => {
}, [toggleLoading, data]);
return (
<NodeViewWrapper className={cls('drag-container', isActive && styles.isActive)}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={cls('drag-content', styles.wrap)}>
<NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}>
<VisibilitySensor onChange={onViewportChange}>
<Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}>
<div
@ -136,7 +134,6 @@ export const FlowWrapper = ({ editor, node, updateAttributes }) => {
</div>
</Resizeable>
</VisibilitySensor>
</div>
</NodeViewWrapper>
);
};

View File

@ -22,9 +22,7 @@ export const IframeWrapper = ({ editor, node, updateAttributes }) => {
);
return (
<NodeViewWrapper className={'drag-container'}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<NodeViewWrapper>
<Resizeable width={width} maxWidth={maxWidth} height={height} isEditable={isEditable} onChangeEnd={onResize}>
<div className={cls(styles.wrap, 'render-wrapper')}>
{url ? (
@ -38,7 +36,6 @@ export const IframeWrapper = ({ editor, node, updateAttributes }) => {
)}
</div>
</Resizeable>
</div>
</NodeViewWrapper>
);
};

View File

@ -69,9 +69,7 @@ export const ImageWrapper = ({ editor, node, updateAttributes }) => {
}, [src, hasTrigger, selectFile, updateAttributes]);
return (
<NodeViewWrapper className={'drag-container'} style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<NodeViewWrapper style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
<Resizeable
className={'render-wrapper'}
width={width || maxWidth}
@ -95,7 +93,6 @@ export const ImageWrapper = ({ editor, node, updateAttributes }) => {
<LazyLoadImage src={src} alt={alt} width={width} height={height} />
)}
</Resizeable>
</div>
</NodeViewWrapper>
);
};

View File

@ -35,15 +35,12 @@ export const KatexWrapper = ({ node, editor }) => {
return (
<NodeViewWrapper
className={'drag-container render-wrapper'}
className={'render-wrapper'}
style={{
backgroundColor,
}}
>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<div className={styles.wrap}>{content}</div>
</div>
</NodeViewWrapper>
);
};

View File

@ -108,9 +108,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
}, [width, height, setCenter]);
return (
<NodeViewWrapper className={cls('drag-container', styles.wrap, isActive && styles.isActive)}>
<div className={'drag-handle'} contentEditable="false" draggable="true" data-drag-handle />
<div className={'drag-content'}>
<NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}>
<VisibilitySensor onChange={onViewportChange}>
<Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}>
<div
@ -140,13 +138,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
<div className={styles.mindHandlerWrap}>
<Tooltip content="居中">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconMindCenter />}
onClick={setCenter}
/>
<Button size="small" theme="borderless" type="tertiary" icon={<IconMindCenter />} onClick={setCenter} />
</Tooltip>
<Tooltip content="缩小">
<Button
@ -170,7 +162,6 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
</div>
</Resizeable>
</VisibilitySensor>
</div>
</NodeViewWrapper>
);
};

View File

@ -16,6 +16,7 @@ import { Countdown } from 'tiptap/core/extensions/countdown';
import { Document } from 'tiptap/core/extensions/document';
import { DocumentChildren } from 'tiptap/core/extensions/document-children';
import { DocumentReference } from 'tiptap/core/extensions/document-reference';
import { Dragable } from 'tiptap/core/extensions/dragable';
import { Dropcursor } from 'tiptap/core/extensions/dropcursor';
import { Emoji } from 'tiptap/core/extensions/emoji';
import { EventEmitter } from 'tiptap/core/extensions/event-emitter';
@ -156,4 +157,5 @@ export const CollaborationKit = [
}),
Title,
DocumentWithTitle,
Dragable,
];

View File

@ -18,6 +18,7 @@ export * from './markdown-source-map';
export * from './mention';
export * from './node';
export * from './position';
export * from './select-node-by-dom';
export * from './table';
export * from './text';
export * from './type';

View File

@ -0,0 +1,87 @@
import { Node, ResolvedPos } from 'prosemirror-model';
import { EditorView } from 'prosemirror-view';
export type ActiveNode = Readonly<{
$pos: ResolvedPos;
node: Node;
el: HTMLElement;
offset: number;
}>;
const nodeIsNotBlock = (node: Node) => !node.type.isBlock;
const nodeIsFirstChild = (pos: ResolvedPos) => {
let parent = pos.parent;
const node = pos.node();
if (parent === node) {
parent = pos.node(pos.depth - 1);
}
if (!parent || parent.type.name === 'doc') return false;
return parent.firstChild === node;
};
const getDOMByPos = (view: EditorView, root: HTMLElement, $pos: ResolvedPos) => {
const { node } = view.domAtPos($pos.pos);
let el: HTMLElement = node as HTMLElement;
let parent = el.parentElement;
while (parent && parent !== root && $pos.pos === view.posAtDOM(parent, 0)) {
el = parent;
parent = parent.parentElement;
}
return el;
};
export const selectRootNodeByDom = (dom: Element, view: EditorView): ActiveNode | null => {
const root = view.dom.parentElement;
if (!root) return null;
let pos = view.posAtDOM(dom, 0);
/**
* img
*/
if (dom.tagName === 'IMG') {
pos -= 1;
}
if (pos === 0) return null;
let $pos = view.state.doc.resolve(pos);
let node = $pos.node();
/**
*
*/
if (node.type.name === 'doc') {
const nodeAtPos = view.state.doc.nodeAt(pos);
if (nodeAtPos && nodeAtPos.type.name !== 'doc' && nodeAtPos.type.name !== 'text') {
node = nodeAtPos;
$pos = view.state.doc.resolve(pos);
const el = view.nodeDOM(pos);
return { node, $pos, el, offset: 0 };
}
}
while (node && (nodeIsNotBlock(node) || nodeIsFirstChild($pos))) {
$pos = view.state.doc.resolve($pos.before());
node = $pos.node();
}
if (node.type.name.includes('table')) {
while (node.type.name !== 'table') {
$pos = view.state.doc.resolve($pos.before());
node = $pos.node();
}
}
$pos = view.state.doc.resolve($pos.pos - $pos.parentOffset);
const el = getDOMByPos(view, root, $pos);
return { node, $pos, el, offset: 1 };
};

View File

@ -2,6 +2,7 @@ import { findParentNode } from '@tiptap/core';
import { Node, ResolvedPos } from 'prosemirror-model';
import { Selection, Transaction } from 'prosemirror-state';
import { CellSelection, TableMap } from 'prosemirror-tables';
import { EditorView } from 'prosemirror-view';
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
@ -220,3 +221,62 @@ export const selectTable = (tr: Transaction) => {
}
return tr;
};
function dropPoint(doc, pos, slice) {
const $pos = doc.resolve(pos);
if (!slice.content.size) {
return pos;
}
let content = slice.content;
for (let i = 0; i < slice.openStart; i++) {
content = content.firstChild.content;
}
for (let pass = 1; pass <= (slice.openStart == 0 && slice.size ? 2 : 1); pass++) {
for (let d = $pos.depth; d >= 0; d--) {
const bias = d == $pos.depth ? 0 : $pos.pos <= ($pos.start(d + 1) + $pos.end(d + 1)) / 2 ? -1 : 1;
const insertPos = $pos.index(d) + (bias > 0 ? 1 : 0);
const parent = $pos.node(d);
let fits = false;
if (pass == 1) {
fits = parent.canReplace(insertPos, insertPos, content);
} else {
const wrapping = parent.contentMatchAt(insertPos).findWrapping(content.firstChild.type);
fits = wrapping && parent.canReplaceWith(insertPos, insertPos, wrapping[0]);
}
if (fits) {
return bias == 0 ? $pos.pos : bias < 0 ? $pos.before(d + 1) : $pos.after(d + 1);
}
}
}
return null;
}
export const removePossibleTable = (view: EditorView, event: DragEvent): Transaction | null => {
const { state } = view;
const $pos = state.selection.$anchor;
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
if (node.type.spec['tableRole'] == 'table') {
const eventPos = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!eventPos) return null;
const slice = view.dragging?.slice;
if (!slice) return null;
const $mouse = view.state.doc.resolve(eventPos.pos);
const insertPos = dropPoint(view.state.doc, $mouse.pos, slice);
if (!insertPos) return null;
let tr = state.tr;
tr = tr.delete($pos.before(d), $pos.after(d));
const pos = tr.mapping.map(insertPos);
tr = tr.replaceRange(pos, pos, slice).scrollIntoView();
return tr;
}
}
return null;
};