import {htmlToBlocks, normalizeBlock} from '@sanity/block-tools' import {type PortableTextBlock, type PortableTextChild} from '@sanity/types' import {isEqual, uniq} from 'lodash' import {type Descendant, Editor, type Node, Range, Transforms} from 'slate' import {ReactEditor} from 'slate-react' import { type EditorChanges, type PortableTextMemberSchemaTypes, type PortableTextSlateEditor, } from '../../types/editor' import {debugWithName} from '../../utils/debug' import {validateValue} from '../../utils/validateValue' import {fromSlateValue, isEqualToEmptyEditor, toSlateValue} from '../../utils/values' const debug = debugWithName('plugin:withInsertData') /** * This plugin handles copy/paste in the editor * */ export function createWithInsertData( change$: EditorChanges, schemaTypes: PortableTextMemberSchemaTypes, keyGenerator: () => string, ) { return function withInsertData(editor: PortableTextSlateEditor): PortableTextSlateEditor { const blockTypeName = schemaTypes.block.name const spanTypeName = schemaTypes.span.name const whitespaceOnPasteMode = schemaTypes.block.options.unstable_whitespaceOnPasteMode const toPlainText = (blocks: PortableTextBlock[]) => { return blocks .map((block) => { if (editor.isTextBlock(block)) { return block.children .map((child: PortableTextChild) => { if (child._type === spanTypeName) { return child.text } return `[${ schemaTypes.inlineObjects.find((t) => t.name === child._type)?.title || 'Object' }]` }) .join('') } return `[${ schemaTypes.blockObjects.find((t) => t.name === block._type)?.title || 'Object' }]` }) .join('\n\n') } editor.setFragmentData = (data: DataTransfer, originEvent) => { const {selection} = editor if (!selection) { return } const [start, end] = Range.edges(selection) const startVoid = Editor.void(editor, {at: start.path}) const endVoid = Editor.void(editor, {at: end.path}) if (Range.isCollapsed(selection) && !startVoid) { return } // Create a fake selection so that we can add a Base64-encoded copy of the // fragment to the HTML, to decode on future pastes. const domRange = ReactEditor.toDOMRange(editor, selection) let contents = domRange.cloneContents() // COMPAT: If the end node is a void node, we need to move the end of the // range from the void node's spacer span, to the end of the void node's // content, since the spacer is before void's content in the DOM. if (endVoid) { const [voidNode] = endVoid const r = domRange.cloneRange() const domNode = ReactEditor.toDOMNode(editor, voidNode) r.setEndAfter(domNode) contents = r.cloneContents() } // Remove any zero-width space spans from the cloned DOM so that they don't // show up elsewhere when pasted. Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach((zw) => { const isNewline = zw.getAttribute('data-slate-zero-width') === 'n' zw.textContent = isNewline ? '\n' : '' }) // Clean up the clipboard HTML for editor spesific attributes Array.from(contents.querySelectorAll('*')).forEach((elm) => { elm.removeAttribute('contentEditable') elm.removeAttribute('data-slate-inline') elm.removeAttribute('data-slate-leaf') elm.removeAttribute('data-slate-node') elm.removeAttribute('data-slate-spacer') elm.removeAttribute('data-slate-string') elm.removeAttribute('data-slate-zero-width') elm.removeAttribute('draggable') for (const key in elm.attributes) { if (elm.hasAttribute(key)) { elm.removeAttribute(key) } } }) const div = contents.ownerDocument.createElement('div') div.appendChild(contents) div.setAttribute('hidden', 'true') contents.ownerDocument.body.appendChild(div) const asHTML = div.innerHTML contents.ownerDocument.body.removeChild(div) const fragment = editor.getFragment() const portableText = fromSlateValue(fragment, blockTypeName) const asJSON = JSON.stringify(portableText) const asPlainText = toPlainText(portableText) data.clearData() data.setData('text/plain', asPlainText) data.setData('text/html', asHTML) data.setData('application/json', asJSON) data.setData('application/x-portable-text', asJSON) debug('text', asPlainText) data.setData('application/x-portable-text-event-origin', originEvent || 'external') debug('Set fragment data', asJSON, asHTML) } editor.insertPortableTextData = (data: DataTransfer): boolean => { if (!editor.selection) { return false } const pText = data.getData('application/x-portable-text') const origin = data.getData('application/x-portable-text-event-origin') debug(`Inserting portable text from ${origin} event`, pText) if (pText) { const parsed = JSON.parse(pText) as PortableTextBlock[] if (Array.isArray(parsed) && parsed.length > 0) { const slateValue = _regenerateKeys( editor, toSlateValue(parsed, {schemaTypes}), keyGenerator, spanTypeName, ) // Validate the result const validation = validateValue(parsed, schemaTypes, keyGenerator) // Bail out if it's not valid if (!validation.valid && !validation.resolution?.autoResolve) { const errorDescription = `${validation.resolution?.description}` change$.next({ type: 'error', level: 'warning', name: 'pasteError', description: errorDescription, data: validation, }) debug('Invalid insert result', validation) return false } _insertFragment(editor, slateValue, schemaTypes) return true } } return false } editor.insertTextOrHTMLData = (data: DataTransfer): boolean => { if (!editor.selection) { debug('No selection, not inserting') return false } change$.next({type: 'loading', isLoading: true}) // This could potentially take some time const html = data.getData('text/html') const text = data.getData('text/plain') if (html || text) { debug('Inserting data', data) let portableText: PortableTextBlock[] let fragment: Node[] let insertedType if (html) { portableText = htmlToBlocks(html, schemaTypes.portableText, { unstable_whitespaceOnPasteMode: whitespaceOnPasteMode, }).map((block) => normalizeBlock(block, {blockTypeName})) as PortableTextBlock[] fragment = toSlateValue(portableText, {schemaTypes}) insertedType = 'HTML' if (portableText.length === 0) { return false } } else { // plain text const blocks = escapeHtml(text) .split(/\n{2,}/) .map((line) => line ? `
${line.replace(/(?:\r\n|\r|\n)/g, '
')}