2023-02-03 00:37:33 +08:00
|
|
|
|
import { NodeSelection } from '@tiptap/pm/state'
|
2023-02-28 04:23:30 +08:00
|
|
|
|
import { NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
2022-06-08 20:10:25 +08:00
|
|
|
|
|
2023-07-01 03:03:49 +08:00
|
|
|
|
import { Editor as CoreEditor } from './Editor.js'
|
|
|
|
|
import { DecorationWithType, NodeViewRendererOptions, NodeViewRendererProps } from './types.js'
|
|
|
|
|
import { isAndroid } from './utilities/isAndroid.js'
|
|
|
|
|
import { isiOS } from './utilities/isiOS.js'
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2024-05-11 20:30:44 +08:00
|
|
|
|
/**
|
|
|
|
|
* Node views are used to customize the rendered DOM structure of a node.
|
|
|
|
|
* @see https://tiptap.dev/guide/node-views
|
|
|
|
|
*/
|
2021-07-27 18:26:24 +08:00
|
|
|
|
export class NodeView<
|
|
|
|
|
Component,
|
2023-02-03 08:19:12 +08:00
|
|
|
|
NodeEditor extends CoreEditor = CoreEditor,
|
2021-07-27 18:26:24 +08:00
|
|
|
|
Options extends NodeViewRendererOptions = NodeViewRendererOptions,
|
|
|
|
|
> implements ProseMirrorNodeView {
|
2021-03-17 04:55:40 +08:00
|
|
|
|
component: Component
|
|
|
|
|
|
2023-02-03 08:19:12 +08:00
|
|
|
|
editor: NodeEditor
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2021-07-27 18:26:24 +08:00
|
|
|
|
options: Options
|
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
extension: NodeViewRendererProps['extension']
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
node: NodeViewRendererProps['node']
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
decorations: NodeViewRendererProps['decorations']
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
innerDecorations: NodeViewRendererProps['innerDecorations']
|
|
|
|
|
|
|
|
|
|
view: NodeViewRendererProps['view']
|
|
|
|
|
|
|
|
|
|
getPos: NodeViewRendererProps['getPos']
|
|
|
|
|
|
|
|
|
|
HTMLAttributes: NodeViewRendererProps['HTMLAttributes']
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
|
|
|
|
isDragging = false
|
|
|
|
|
|
2021-07-27 18:26:24 +08:00
|
|
|
|
constructor(component: Component, props: NodeViewRendererProps, options?: Partial<Options>) {
|
2021-03-17 04:55:40 +08:00
|
|
|
|
this.component = component
|
2023-02-03 08:19:12 +08:00
|
|
|
|
this.editor = props.editor as NodeEditor
|
2021-07-27 18:26:24 +08:00
|
|
|
|
this.options = {
|
|
|
|
|
stopEvent: null,
|
|
|
|
|
ignoreMutation: null,
|
|
|
|
|
...options,
|
|
|
|
|
} as Options
|
2021-03-17 04:55:40 +08:00
|
|
|
|
this.extension = props.extension
|
|
|
|
|
this.node = props.node
|
2023-02-28 04:23:30 +08:00
|
|
|
|
this.decorations = props.decorations as DecorationWithType[]
|
2024-08-20 22:25:16 +08:00
|
|
|
|
this.innerDecorations = props.innerDecorations
|
|
|
|
|
this.view = props.view
|
|
|
|
|
this.HTMLAttributes = props.HTMLAttributes
|
2021-03-17 04:55:40 +08:00
|
|
|
|
this.getPos = props.getPos
|
|
|
|
|
this.mount()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mount() {
|
|
|
|
|
// eslint-disable-next-line
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2022-06-20 17:45:37 +08:00
|
|
|
|
get dom(): HTMLElement {
|
|
|
|
|
return this.editor.view.dom as HTMLElement
|
2021-03-17 04:55:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
2022-06-20 17:45:37 +08:00
|
|
|
|
get contentDOM(): HTMLElement | null {
|
2021-03-17 04:55:40 +08:00
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onDragStart(event: DragEvent) {
|
|
|
|
|
const { view } = this.editor
|
2023-02-03 00:37:33 +08:00
|
|
|
|
const target = event.target as HTMLElement
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2021-04-20 23:18:59 +08:00
|
|
|
|
// get the drag handle element
|
|
|
|
|
// `closest` is not available for text nodes so we may have to use its parent
|
|
|
|
|
const dragHandle = target.nodeType === 3
|
|
|
|
|
? target.parentElement?.closest('[data-drag-handle]')
|
|
|
|
|
: target.closest('[data-drag-handle]')
|
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
if (!this.dom || this.contentDOM?.contains(target) || !dragHandle) {
|
2021-03-17 04:55:40 +08:00
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-20 23:18:59 +08:00
|
|
|
|
let x = 0
|
|
|
|
|
let y = 0
|
|
|
|
|
|
|
|
|
|
// calculate offset for drag element if we use a different drag handle element
|
|
|
|
|
if (this.dom !== dragHandle) {
|
|
|
|
|
const domBox = this.dom.getBoundingClientRect()
|
|
|
|
|
const handleBox = dragHandle.getBoundingClientRect()
|
|
|
|
|
|
2022-05-09 16:03:13 +08:00
|
|
|
|
// In React, we have to go through nativeEvent to reach offsetX/offsetY.
|
|
|
|
|
const offsetX = event.offsetX ?? (event as any).nativeEvent?.offsetX
|
|
|
|
|
const offsetY = event.offsetY ?? (event as any).nativeEvent?.offsetY
|
2022-05-11 01:51:52 +08:00
|
|
|
|
|
2022-05-09 16:03:13 +08:00
|
|
|
|
x = handleBox.x - domBox.x + offsetX
|
|
|
|
|
y = handleBox.y - domBox.y + offsetY
|
2021-04-20 23:18:59 +08:00
|
|
|
|
}
|
2021-04-09 05:53:47 +08:00
|
|
|
|
|
|
|
|
|
event.dataTransfer?.setDragImage(this.dom, x, y)
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
const pos = this.getPos()
|
|
|
|
|
|
|
|
|
|
if (typeof pos !== 'number') {
|
|
|
|
|
return
|
|
|
|
|
}
|
2021-04-20 23:18:59 +08:00
|
|
|
|
// we need to tell ProseMirror that we want to move the whole node
|
|
|
|
|
// so we create a NodeSelection
|
2024-08-20 22:25:16 +08:00
|
|
|
|
const selection = NodeSelection.create(view.state.doc, pos)
|
2021-03-17 04:55:40 +08:00
|
|
|
|
const transaction = view.state.tr.setSelection(selection)
|
|
|
|
|
|
|
|
|
|
view.dispatch(transaction)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stopEvent(event: Event) {
|
|
|
|
|
if (!this.dom) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof this.options.stopEvent === 'function') {
|
2021-07-27 18:26:24 +08:00
|
|
|
|
return this.options.stopEvent({ event })
|
2021-03-17 04:55:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
const target = event.target as HTMLElement
|
2021-03-17 04:55:40 +08:00
|
|
|
|
const isInElement = this.dom.contains(target) && !this.contentDOM?.contains(target)
|
|
|
|
|
|
2021-03-18 20:54:48 +08:00
|
|
|
|
// any event from child nodes should be handled by ProseMirror
|
2021-03-17 04:55:40 +08:00
|
|
|
|
if (!isInElement) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-09 19:12:54 +08:00
|
|
|
|
const isDragEvent = event.type.startsWith('drag')
|
2021-10-23 03:27:58 +08:00
|
|
|
|
const isDropEvent = event.type === 'drop'
|
2023-02-03 00:37:33 +08:00
|
|
|
|
const isInput = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable
|
2021-03-18 20:54:48 +08:00
|
|
|
|
|
|
|
|
|
// any input event within node views should be ignored by ProseMirror
|
2023-02-09 19:12:54 +08:00
|
|
|
|
if (isInput && !isDropEvent && !isDragEvent) {
|
2021-03-18 20:54:48 +08:00
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-17 04:55:40 +08:00
|
|
|
|
const { isEditable } = this.editor
|
|
|
|
|
const { isDragging } = this
|
|
|
|
|
const isDraggable = !!this.node.type.spec.draggable
|
|
|
|
|
const isSelectable = NodeSelection.isSelectable(this.node)
|
|
|
|
|
const isCopyEvent = event.type === 'copy'
|
|
|
|
|
const isPasteEvent = event.type === 'paste'
|
|
|
|
|
const isCutEvent = event.type === 'cut'
|
|
|
|
|
const isClickEvent = event.type === 'mousedown'
|
|
|
|
|
|
|
|
|
|
// ProseMirror tries to drag selectable nodes
|
|
|
|
|
// even if `draggable` is set to `false`
|
|
|
|
|
// this fix prevents that
|
|
|
|
|
if (!isDraggable && isSelectable && isDragEvent) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isDraggable && isDragEvent && !isDragging) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// we have to store that dragging started
|
|
|
|
|
if (isDraggable && isEditable && !isDragging && isClickEvent) {
|
|
|
|
|
const dragHandle = target.closest('[data-drag-handle]')
|
2023-02-03 00:37:33 +08:00
|
|
|
|
const isValidDragHandle = dragHandle && (this.dom === dragHandle || this.dom.contains(dragHandle))
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
|
|
|
|
if (isValidDragHandle) {
|
|
|
|
|
this.isDragging = true
|
2021-03-18 20:54:48 +08:00
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
document.addEventListener(
|
|
|
|
|
'dragend',
|
|
|
|
|
() => {
|
|
|
|
|
this.isDragging = false
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
)
|
|
|
|
|
|
2023-02-09 19:12:54 +08:00
|
|
|
|
document.addEventListener(
|
|
|
|
|
'drop',
|
|
|
|
|
() => {
|
|
|
|
|
this.isDragging = false
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
)
|
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
document.addEventListener(
|
|
|
|
|
'mouseup',
|
|
|
|
|
() => {
|
|
|
|
|
this.isDragging = false
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
)
|
2021-03-17 04:55:40 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// these events are handled by prosemirror
|
|
|
|
|
if (
|
|
|
|
|
isDragging
|
2021-10-23 03:27:58 +08:00
|
|
|
|
|| isDropEvent
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|| isCopyEvent
|
|
|
|
|
|| isPasteEvent
|
|
|
|
|
|| isCutEvent
|
|
|
|
|
|| (isClickEvent && isSelectable)
|
|
|
|
|
) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
/**
|
|
|
|
|
* Called when a DOM [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) or a selection change happens within the view.
|
|
|
|
|
* @return `false` if the editor should re-read the selection or re-parse the range around the mutation
|
|
|
|
|
* @return `true` if it can safely be ignored.
|
|
|
|
|
*/
|
2023-02-03 00:37:33 +08:00
|
|
|
|
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
|
2021-04-28 03:07:12 +08:00
|
|
|
|
if (!this.dom || !this.contentDOM) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-27 00:44:02 +08:00
|
|
|
|
if (typeof this.options.ignoreMutation === 'function') {
|
2021-07-27 18:26:24 +08:00
|
|
|
|
return this.options.ignoreMutation({ mutation })
|
2021-07-27 00:44:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
2021-04-28 03:07:12 +08:00
|
|
|
|
// a leaf/atom node is like a black box for ProseMirror
|
|
|
|
|
// and should be fully handled by the node view
|
2021-06-14 21:56:24 +08:00
|
|
|
|
if (this.node.isLeaf || this.node.isAtom) {
|
2021-04-28 03:07:12 +08:00
|
|
|
|
return true
|
|
|
|
|
}
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2021-04-28 03:07:12 +08:00
|
|
|
|
// ProseMirror should handle any selections
|
|
|
|
|
if (mutation.type === 'selection') {
|
2021-03-17 04:55:40 +08:00
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-25 08:33:48 +08:00
|
|
|
|
// try to prevent a bug on iOS and Android that will break node views on enter
|
2021-04-28 03:07:12 +08:00
|
|
|
|
// this is because ProseMirror can’t preventDispatch on enter
|
|
|
|
|
// this will lead to a re-render of the node view on enter
|
|
|
|
|
// see: https://github.com/ueberdosis/tiptap/issues/1214
|
2023-06-25 08:33:48 +08:00
|
|
|
|
// see: https://github.com/ueberdosis/tiptap/issues/2534
|
2021-11-18 19:38:02 +08:00
|
|
|
|
if (
|
|
|
|
|
this.dom.contains(mutation.target)
|
|
|
|
|
&& mutation.type === 'childList'
|
2023-06-25 08:33:48 +08:00
|
|
|
|
&& (isiOS() || isAndroid())
|
2021-11-18 19:38:02 +08:00
|
|
|
|
&& this.editor.isFocused
|
|
|
|
|
) {
|
2021-04-28 03:07:12 +08:00
|
|
|
|
const changedNodes = [
|
|
|
|
|
...Array.from(mutation.addedNodes),
|
|
|
|
|
...Array.from(mutation.removedNodes),
|
|
|
|
|
] as HTMLElement[]
|
|
|
|
|
|
|
|
|
|
// we’ll check if every changed node is contentEditable
|
|
|
|
|
// to make sure it’s probably mutated by ProseMirror
|
|
|
|
|
if (changedNodes.every(node => node.isContentEditable)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// we will allow mutation contentDOM with attributes
|
|
|
|
|
// so we can for example adding classes within our node view
|
|
|
|
|
if (this.contentDOM === mutation.target && mutation.type === 'attributes') {
|
2021-03-17 04:55:40 +08:00
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-28 03:07:12 +08:00
|
|
|
|
// ProseMirror should handle any changes within contentDOM
|
|
|
|
|
if (this.contentDOM.contains(mutation.target)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2021-04-28 03:07:12 +08:00
|
|
|
|
return true
|
2021-03-17 04:55:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
/**
|
|
|
|
|
* Update the attributes of the prosemirror node.
|
|
|
|
|
*/
|
|
|
|
|
updateAttributes(attributes: Record<string, any>): void {
|
2021-06-14 21:35:43 +08:00
|
|
|
|
this.editor.commands.command(({ tr }) => {
|
|
|
|
|
const pos = this.getPos()
|
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
if (typeof pos !== 'number') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-14 21:35:43 +08:00
|
|
|
|
tr.setNodeMarkup(pos, undefined, {
|
|
|
|
|
...this.node.attrs,
|
|
|
|
|
...attributes,
|
|
|
|
|
})
|
2021-03-17 04:55:40 +08:00
|
|
|
|
|
2021-06-14 21:35:43 +08:00
|
|
|
|
return true
|
|
|
|
|
})
|
2021-03-17 04:55:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 22:25:16 +08:00
|
|
|
|
/**
|
|
|
|
|
* Delete the node.
|
|
|
|
|
*/
|
2021-05-19 06:01:49 +08:00
|
|
|
|
deleteNode(): void {
|
|
|
|
|
const from = this.getPos()
|
2024-08-20 22:25:16 +08:00
|
|
|
|
|
|
|
|
|
if (typeof from !== 'number') {
|
|
|
|
|
return
|
|
|
|
|
}
|
2021-05-19 06:01:49 +08:00
|
|
|
|
const to = from + this.node.nodeSize
|
|
|
|
|
|
|
|
|
|
this.editor.commands.deleteRange({ from, to })
|
|
|
|
|
}
|
2021-03-17 04:55:40 +08:00
|
|
|
|
}
|