UNPKG

@tiptap/core

Version:

headless rich text editor

374 lines (309 loc) 10.6 kB
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 }