@tiptap/core
Version:
headless rich text editor
374 lines (309 loc) • 10.6 kB
text/typescript
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Fragment } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
import { Plugin } from '@tiptap/pm/state'
import { CommandManager } from './CommandManager.js'
import type { Editor } from './Editor.js'
import { createChainableState } from './helpers/createChainableState.js'
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
import type { CanCommands, ChainedCommands, ExtendedRegExpMatchArray, Range, SingleCommands } from './types.js'
import { isNumber } from './utilities/isNumber.js'
import { isRegExp } from './utilities/isRegExp.js'
export type PasteRuleMatch = {
index: number
text: string
replaceWith?: string
match?: RegExpMatchArray
data?: Record<string, any>
}
export type PasteRuleFinder =
| RegExp
| ((text: string, event?: ClipboardEvent | null) => PasteRuleMatch[] | null | undefined)
/**
* Paste rules are used to react to pasted content.
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules
*/
export class PasteRule {
find: PasteRuleFinder
handler: (props: {
state: EditorState
range: Range
match: ExtendedRegExpMatchArray
commands: SingleCommands
chain: () => ChainedCommands
can: () => CanCommands
pasteEvent: ClipboardEvent | null
dropEvent: DragEvent | null
}) => void | null
constructor(config: {
find: PasteRuleFinder
handler: (props: {
can: () => CanCommands
chain: () => ChainedCommands
commands: SingleCommands
dropEvent: DragEvent | null
match: ExtendedRegExpMatchArray
pasteEvent: ClipboardEvent | null
range: Range
state: EditorState
}) => void | null
}) {
this.find = config.find
this.handler = config.handler
}
}
const pasteRuleMatcherHandler = (
text: string,
find: PasteRuleFinder,
event?: ClipboardEvent | null,
): ExtendedRegExpMatchArray[] => {
if (isRegExp(find)) {
return [...text.matchAll(find)]
}
const matches = find(text, event)
if (!matches) {
return []
}
return matches.map(pasteRuleMatch => {
const result: ExtendedRegExpMatchArray = [pasteRuleMatch.text]
result.index = pasteRuleMatch.index
result.input = text
result.data = pasteRuleMatch.data
if (pasteRuleMatch.replaceWith) {
if (!pasteRuleMatch.text.includes(pasteRuleMatch.replaceWith)) {
console.warn('[tiptap warn]: "pasteRuleMatch.replaceWith" must be part of "pasteRuleMatch.text".')
}
result.push(pasteRuleMatch.replaceWith)
}
return result
})
}
function run(config: {
editor: Editor
state: EditorState
from: number
to: number
rule: PasteRule
pasteEvent: ClipboardEvent | null
dropEvent: DragEvent | null
}): boolean {
const { editor, state, from, to, rule, pasteEvent, dropEvent } = config
const { commands, chain, can } = new CommandManager({
editor,
state,
})
const handlers: (void | null)[] = []
state.doc.nodesBetween(from, to, (node, pos) => {
// Skip code blocks and non-textual nodes.
// Be defensive: `node` may be a Fragment without a `type`. Only text,
// inline, or textblock nodes are processed by paste rules.
if (node.type?.spec?.code || !(node.isText || node.isTextblock || node.isInline)) {
return
}
// For textblock and inline/text nodes, compute the range relative to the node.
// Prefer `node.nodeSize` when available (some Node shapes expose this),
// otherwise fall back to `node.content?.size`. Default to 0 if neither exists.
const contentSize = node.content?.size ?? node.nodeSize ?? 0
const resolvedFrom = Math.max(from, pos)
const resolvedTo = Math.min(to, pos + contentSize)
// If the resolved range is empty or invalid for this node, skip it. This
// avoids calling `textBetween` with start > end which can cause internal
// Fragment/Node traversal to access undefined `nodeSize` values.
if (resolvedFrom >= resolvedTo) {
return
}
const textToMatch = node.isText
? node.text || ''
: node.textBetween(resolvedFrom - pos, resolvedTo - pos, undefined, '\ufffc')
const matches = pasteRuleMatcherHandler(textToMatch, rule.find, pasteEvent)
matches.forEach(match => {
if (match.index === undefined) {
return
}
const start = resolvedFrom + match.index + 1
const end = start + match[0].length
const range = {
from: state.tr.mapping.map(start),
to: state.tr.mapping.map(end),
}
const handler = rule.handler({
state,
range,
match,
commands,
chain,
can,
pasteEvent,
dropEvent,
})
handlers.push(handler)
})
})
const success = handlers.every(handler => handler !== null)
return success
}
// When dragging across editors, must get another editor instance to delete selection content.
let tiptapDragFromOtherEditor: Editor | null = null
const createClipboardPasteEvent = (text: string) => {
const event = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
})
event.clipboardData?.setData('text/html', text)
return event
}
/**
* Create an paste rules plugin. When enabled, it will cause pasted
* text that matches any of the given rules to trigger the rule’s
* action.
*/
export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): Plugin[] {
const { editor, rules } = props
let dragSourceElement: Element | null = null
let isPastedFromProseMirror = false
let isDroppedFromProseMirror = false
let pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null
let dropEvent: DragEvent | null
try {
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
} catch {
dropEvent = null
}
const processEvent = ({
state,
from,
to,
rule,
pasteEvt,
}: {
state: EditorState
from: number
to: { b: number }
rule: PasteRule
pasteEvt: ClipboardEvent | null
}) => {
const tr = state.tr
const chainableState = createChainableState({
state,
transaction: tr,
})
const handler = run({
editor,
state: chainableState,
from: Math.max(from - 1, 0),
to: to.b - 1,
rule,
pasteEvent: pasteEvt,
dropEvent,
})
if (!handler || !tr.steps.length) {
return
}
try {
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
} catch {
dropEvent = null
}
pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null
return tr
}
const plugins = rules.map(rule => {
return new Plugin({
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: DragEvent) => {
dragSourceElement = view.dom.parentElement?.contains(event.target as Element) ? view.dom.parentElement : null
if (dragSourceElement) {
tiptapDragFromOtherEditor = editor
}
}
const handleDragend = () => {
if (tiptapDragFromOtherEditor) {
tiptapDragFromOtherEditor = null
}
}
window.addEventListener('dragstart', handleDragstart)
window.addEventListener('dragend', handleDragend)
return {
destroy() {
window.removeEventListener('dragstart', handleDragstart)
window.removeEventListener('dragend', handleDragend)
},
}
},
props: {
handleDOMEvents: {
drop: (view, event: Event) => {
isDroppedFromProseMirror = dragSourceElement === view.dom.parentElement
dropEvent = event as DragEvent
if (!isDroppedFromProseMirror) {
const dragFromOtherEditor = tiptapDragFromOtherEditor
if (dragFromOtherEditor?.isEditable) {
// setTimeout to avoid the wrong content after drop, timeout arg can't be empty or 0
setTimeout(() => {
const selection = dragFromOtherEditor.state.selection
if (selection) {
dragFromOtherEditor.commands.deleteRange({ from: selection.from, to: selection.to })
}
}, 10)
}
}
return false
},
paste: (_view, event: Event) => {
const html = (event as ClipboardEvent).clipboardData?.getData('text/html')
pasteEvent = event as ClipboardEvent
isPastedFromProseMirror = !!html?.includes('data-pm-slice')
return false
},
},
},
appendTransaction: (transactions, oldState, state) => {
const transaction = transactions[0]
const isPaste = transaction.getMeta('uiEvent') === 'paste' && !isPastedFromProseMirror
const isDrop = transaction.getMeta('uiEvent') === 'drop' && !isDroppedFromProseMirror
// if PasteRule is triggered by insertContent()
const simulatedPasteMeta = transaction.getMeta('applyPasteRules') as
| undefined
| { from: number; text: string | ProseMirrorNode | Fragment }
const isSimulatedPaste = !!simulatedPasteMeta
if (!isPaste && !isDrop && !isSimulatedPaste) {
return
}
// Handle simulated paste
if (isSimulatedPaste) {
let { text } = simulatedPasteMeta
if (typeof text === 'string') {
text = text as string
} else {
text = getHTMLFromFragment(Fragment.from(text), state.schema)
}
const { from } = simulatedPasteMeta
const to = from + text.length
const pasteEvt = createClipboardPasteEvent(text)
return processEvent({
rule,
state,
from,
to: { b: to },
pasteEvt,
})
}
// handle actual paste/drop
const from = oldState.doc.content.findDiffStart(state.doc.content)
const to = oldState.doc.content.findDiffEnd(state.doc.content)
// stop if there is no changed range
if (!isNumber(from) || !to || from === to.b) {
return
}
return processEvent({
rule,
state,
from,
to,
pasteEvt: pasteEvent,
})
},
})
})
return plugins
}