diff --git a/packages/client/src/tiptap/core/extensions/dragable.ts b/packages/client/src/tiptap/core/extensions/dragable.ts new file mode 100644 index 00000000..d5e94092 --- /dev/null +++ b/packages/client/src/tiptap/core/extensions/dragable.ts @@ -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; + }, + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/tiptap/core/extensions/paragraph.ts b/packages/client/src/tiptap/core/extensions/paragraph.ts index c13cae25..7e25a7f4 100644 --- a/packages/client/src/tiptap/core/extensions/paragraph.ts +++ b/packages/client/src/tiptap/core/extensions/paragraph.ts @@ -1,12 +1,3 @@ import TitapParagraph from '@tiptap/extension-paragraph'; -import { ReactNodeViewRenderer } from '@tiptap/react'; -import { ParagraphWrapper } from '../wrappers/paragraph'; - -export const Paragraph = TitapParagraph.extend({ - draggable: true, - - addNodeView() { - return ReactNodeViewRenderer(ParagraphWrapper); - }, -}); +export const Paragraph = TitapParagraph.extend({}); diff --git a/packages/client/src/tiptap/core/extensions/task-item.ts b/packages/client/src/tiptap/core/extensions/task-item.ts index 3d1c09b0..5583815a 100644 --- a/packages/client/src/tiptap/core/extensions/task-item.ts +++ b/packages/client/src/tiptap/core/extensions/task-item.ts @@ -1,45 +1,135 @@ -import { wrappingInputRule } from '@tiptap/core'; -import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item'; -import { Plugin } from 'prosemirror-state'; -import { findParentNodeClosestToPos } from 'prosemirror-utils'; -import { PARSE_HTML_PRIORITY_HIGHEST } from 'tiptap/core/constants'; +import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'; + +export interface TaskItemOptions { + nested: boolean; + HTMLAttributes: Record; +} + +export const inputRegex = /^\s*(\[([ |x])\])\s$/; + +export const TaskItem = Node.create({ + 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() { return [ { - tag: 'li.task-list-item', - priority: PARSE_HTML_PRIORITY_HIGHEST, + tag: `li.task-list-item`, + priority: 51, }, ]; }, - addInputRules() { + renderHTML({ node, HTMLAttributes }) { return [ - ...this.parent(), - wrappingInputRule({ - find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/, - type: this.type, - getAttributes: (match) => ({ - checked: 'xX'.includes(match[match.length - 1]), - }), - }), + 'li', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }), + [ + 'label', + [ + '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() { return ({ node, HTMLAttributes, getPos, editor }) => { 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'); checkboxWrapper.contentEditable = 'false'; + checkbox.type = 'checkbox'; + checkbox.addEventListener('change', (event) => { + // if the editor isn’t 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]) => { listItem.setAttribute(key, value); }); listItem.dataset.checked = node.attrs.checked; + if (node.attrs.checked) { + checkbox.setAttribute('checked', 'checked'); + } + + checkboxWrapper.append(checkbox, checkboxStyler); listItem.append(checkboxWrapper, content); Object.entries(HTMLAttributes).forEach(([key, value]) => { @@ -55,61 +145,34 @@ const CustomTaskItem = BuiltInTaskItem.extend({ } listItem.dataset.checked = updatedNode.attrs.checked; + if (updatedNode.attrs.checked) { + checkbox.setAttribute('checked', 'checked'); + } else { + checkbox.removeAttribute('checked'); + } + return true; }, }; }; }, - addProseMirrorPlugins() { - const extensionThis = this; - + addInputRules() { return [ - new Plugin({ - props: { - handleClick: (view, pos, event) => { - const state = view.state; - const schema = state.schema; - - const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); - const position = state.doc.resolve(coordinates.pos); - const parentList = findParentNodeClosestToPos(position, function (node) { - return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem; - }); - if (!parentList) { - 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; - }, - }, + wrappingInputRule({ + find: inputRegex, + type: this.type, + getAttributes: (match) => ({ + checked: match[match.length - 1] === 'x', + }), + }), + wrappingInputRule({ + find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/, + type: this.type, + getAttributes: (match) => ({ + checked: 'xX'.includes(match[match.length - 1]), + }), }), ]; }, }); - -export const TaskItem = CustomTaskItem.configure({ nested: true }); diff --git a/packages/client/src/tiptap/core/styles/base.scss b/packages/client/src/tiptap/core/styles/base.scss index 91bd3c96..8a51a3e0 100644 --- a/packages/client/src/tiptap/core/styles/base.scss +++ b/packages/client/src/tiptap/core/styles/base.scss @@ -70,6 +70,7 @@ } p { + margin-top: 0.75rem; margin-bottom: 0; font-size: 1em; font-weight: normal; diff --git a/packages/client/src/tiptap/core/styles/drag.scss b/packages/client/src/tiptap/core/styles/drag.scss new file mode 100644 index 00000000..2841b243 --- /dev/null +++ b/packages/client/src/tiptap/core/styles/drag.scss @@ -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,'); + background-repeat: no-repeat; + background-size: contain; + background-position: center; +} diff --git a/packages/client/src/tiptap/core/styles/index.scss b/packages/client/src/tiptap/core/styles/index.scss index 72c9d469..db29c736 100644 --- a/packages/client/src/tiptap/core/styles/index.scss +++ b/packages/client/src/tiptap/core/styles/index.scss @@ -17,3 +17,4 @@ @import './table.scss'; @import './title.scss'; @import './kityminder.scss'; +@import './drag.scss'; diff --git a/packages/client/src/tiptap/core/styles/list.scss b/packages/client/src/tiptap/core/styles/list.scss index c02f1cd5..097107db 100644 --- a/packages/client/src/tiptap/core/styles/list.scss +++ b/packages/client/src/tiptap/core/styles/list.scss @@ -52,7 +52,7 @@ padding-left: 16px; cursor: pointer; - > span { + > label { position: absolute; top: 6px; left: 0; @@ -63,6 +63,10 @@ border: 1px solid var(--semi-color-border); border-radius: 2px; + > input { + display: none; + } + &::after { position: absolute; top: -0.357143px; @@ -87,7 +91,7 @@ &[data-checked='true'] { color: var(--semi-color-text-2); - > span { + > label { background-color: var(--semi-color-primary); &::after { diff --git a/packages/client/src/tiptap/core/wrappers/dragable/index.module.scss b/packages/client/src/tiptap/core/wrappers/dragable/index.module.scss index 798e77de..8487d957 100644 --- a/packages/client/src/tiptap/core/wrappers/dragable/index.module.scss +++ b/packages/client/src/tiptap/core/wrappers/dragable/index.module.scss @@ -5,9 +5,9 @@ .dragHandle { position: absolute; top: 0.3rem; - left: -1.5rem; - width: 1rem; - height: 1rem; + left: -24px; + width: 16px; + height: 16px; cursor: grab; opacity: 0; background-image: url('data:image/svg+xml;charset=UTF-8,'); diff --git a/packages/client/src/tiptap/core/wrappers/paragraph/index.module.scss b/packages/client/src/tiptap/core/wrappers/paragraph/index.module.scss deleted file mode 100644 index 98cee0ac..00000000 --- a/packages/client/src/tiptap/core/wrappers/paragraph/index.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.paragraph { - margin-top: 0.75em; -} diff --git a/packages/client/src/tiptap/core/wrappers/paragraph/index.tsx b/packages/client/src/tiptap/core/wrappers/paragraph/index.tsx deleted file mode 100644 index cc706e85..00000000 --- a/packages/client/src/tiptap/core/wrappers/paragraph/index.tsx +++ /dev/null @@ -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 ( - - - - ); -}; diff --git a/packages/client/src/tiptap/editor/collaboration/kit.ts b/packages/client/src/tiptap/editor/collaboration/kit.ts index 50d23f17..deba6268 100644 --- a/packages/client/src/tiptap/editor/collaboration/kit.ts +++ b/packages/client/src/tiptap/editor/collaboration/kit.ts @@ -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'; @@ -98,6 +99,7 @@ export const CollaborationKit = [ CodeBlock, Color, ColorHighlighter, + Dragable, Dropcursor, EventEmitter, Focus,