import extend from 'extend'; import Delta from 'quill-delta'; import { Attributor, ClassAttributor, EmbedBlot, Scope, StyleAttributor, BlockBlot, } from 'parchment'; import { BlockEmbed } from '../blots/block'; import Quill from '../core/quill'; import logger from '../core/logger'; import Module from '../core/module'; import { AlignAttribute, AlignStyle } from '../formats/align'; import { BackgroundStyle } from '../formats/background'; import CodeBlock from '../formats/code'; import { ColorStyle } from '../formats/color'; import { DirectionAttribute, DirectionStyle } from '../formats/direction'; import { FontStyle } from '../formats/font'; import { SizeStyle } from '../formats/size'; const debug = logger('quill:clipboard'); const CLIPBOARD_CONFIG = [ [Node.TEXT_NODE, matchText], [Node.TEXT_NODE, matchNewline], ['br', matchBreak], [Node.ELEMENT_NODE, matchNewline], [Node.ELEMENT_NODE, matchBlot], [Node.ELEMENT_NODE, matchAttributor], [Node.ELEMENT_NODE, matchStyles], ['li', matchIndent], ['ol, ul', matchList], ['pre', matchCodeBlock], ['tr', matchTable], ['b', matchAlias.bind(matchAlias, 'bold')], ['i', matchAlias.bind(matchAlias, 'italic')], ['style', matchIgnore], ]; const ATTRIBUTE_ATTRIBUTORS = [AlignAttribute, DirectionAttribute].reduce( (memo, attr) => { memo[attr.keyName] = attr; return memo; }, {}, ); const STYLE_ATTRIBUTORS = [ AlignStyle, BackgroundStyle, ColorStyle, DirectionStyle, FontStyle, SizeStyle, ].reduce((memo, attr) => { memo[attr.keyName] = attr; return memo; }, {}); class Clipboard extends Module { constructor(quill, options) { super(quill, options); this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false)); this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true)); this.quill.root.addEventListener('paste', this.onCapturePaste.bind(this)); this.matchers = []; CLIPBOARD_CONFIG.concat(this.options.matchers).forEach( ([selector, matcher]) => { this.addMatcher(selector, matcher); }, ); } addMatcher(selector, matcher) { this.matchers.push([selector, matcher]); } convert({ html, text }, formats = {}) { if (formats[CodeBlock.blotName]) { return new Delta().insert(text, { [CodeBlock.blotName]: formats[CodeBlock.blotName], }); } if (!html) { return new Delta().insert(text || ''); } const doc = new DOMParser().parseFromString(html, 'text/html'); const container = doc.body; const nodeMatches = new WeakMap(); const [elementMatchers, textMatchers] = this.prepareMatching( container, nodeMatches, ); const delta = traverse( this.quill.scroll, container, elementMatchers, textMatchers, nodeMatches, ); // Remove trailing newline if ( deltaEndsWith(delta, '\n') && (delta.ops[delta.ops.length - 1].attributes == null || formats.table) ) { return delta.compose(new Delta().retain(delta.length() - 1).delete(1)); } return delta; } dangerouslyPasteHTML(index, html, source = Quill.sources.API) { if (typeof index === 'string') { const delta = this.convert({ html: index, text: '' }); this.quill.setContents(delta, html); this.quill.setSelection(0, Quill.sources.SILENT); } else { const paste = this.convert({ html, text: '' }); this.quill.updateContents( new Delta().retain(index).concat(paste), source, ); this.quill.setSelection(index + paste.length(), Quill.sources.SILENT); } } onCaptureCopy(e, isCut = false) { if (e.defaultPrevented) return; e.preventDefault(); const [range] = this.quill.selection.getRange(); if (range == null) return; const { html, text } = this.onCopy(range, isCut); e.clipboardData.setData('text/plain', text); e.clipboardData.setData('text/html', html); if (isCut) { this.quill.deleteText(range, Quill.sources.USER); } } onCapturePaste(e) { if (e.defaultPrevented || !this.quill.isEnabled()) return; e.preventDefault(); const range = this.quill.getSelection(true); if (range == null) return; const html = e.clipboardData.getData('text/html'); const text = e.clipboardData.getData('text/plain'); const files = Array.from(e.clipboardData.files || []); if (!html && files.length > 0) { this.quill.uploader.upload(range, files); } else { this.onPaste(range, { html, text }); } } onCopy(range) { const text = this.quill.getText(range); const html = this.quill.getSemanticHTML(range); return { html, text }; } onPaste(range, { text, html }) { const formats = this.quill.getFormat(range.index); const pastedDelta = this.convert({ text, html }, formats); debug.log('onPaste', pastedDelta, { text, html }); const delta = new Delta() .retain(range.index) .delete(range.length) .concat(pastedDelta); this.quill.updateContents(delta, Quill.sources.USER); // range.length contributes to delta.length() this.quill.setSelection( delta.length() - range.length, Quill.sources.SILENT, ); this.quill.scrollIntoView(); } prepareMatching(container, nodeMatches) { const elementMatchers = []; const textMatchers = []; this.matchers.forEach(pair => { const [selector, matcher] = pair; switch (selector) { case Node.TEXT_NODE: textMatchers.push(matcher); break; case Node.ELEMENT_NODE: elementMatchers.push(matcher); break; default: Array.from(container.querySelectorAll(selector)).forEach(node => { if (nodeMatches.has(node)) { const matches = nodeMatches.get(node); matches.push(matcher); } else { nodeMatches.set(node, [matcher]); } }); break; } }); return [elementMatchers, textMatchers]; } } Clipboard.DEFAULTS = { matchers: [], }; function applyFormat(delta, format, value) { if (typeof format === 'object') { return Object.keys(format).reduce((newDelta, key) => { return applyFormat(newDelta, key, format[key]); }, delta); } return delta.reduce((newDelta, op) => { if (op.attributes && op.attributes[format]) { return newDelta.push(op); } return newDelta.insert( op.insert, extend({}, { [format]: value }, op.attributes), ); }, new Delta()); } function deltaEndsWith(delta, text) { let endText = ''; for ( let i = delta.ops.length - 1; i >= 0 && endText.length < text.length; --i // eslint-disable-line no-plusplus ) { const op = delta.ops[i]; if (typeof op.insert !== 'string') break; endText = op.insert + endText; } return endText.slice(-1 * text.length) === text; } function isLine(node) { if (node.childNodes.length === 0) return false; // Exclude embed blocks return [ 'address', 'article', 'blockquote', 'canvas', 'dd', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'iframe', 'li', 'main', 'nav', 'ol', 'output', 'p', 'pre', 'section', 'table', 'td', 'tr', 'ul', 'video', ].includes(node.tagName.toLowerCase()); } const preNodes = new WeakMap(); function isPre(node) { if (node == null) return false; if (!preNodes.has(node)) { if (node.tagName === 'PRE') { preNodes.set(node, true); } else { preNodes.set(node, isPre(node.parentNode)); } } return preNodes.get(node); } function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) { // Post-order if (node.nodeType === node.TEXT_NODE) { return textMatchers.reduce((delta, matcher) => { return matcher(node, delta, scroll); }, new Delta()); } if (node.nodeType === node.ELEMENT_NODE) { return Array.from(node.childNodes || []).reduce((delta, childNode) => { let childrenDelta = traverse( scroll, childNode, elementMatchers, textMatchers, nodeMatches, ); if (childNode.nodeType === node.ELEMENT_NODE) { childrenDelta = elementMatchers.reduce((reducedDelta, matcher) => { return matcher(childNode, reducedDelta, scroll); }, childrenDelta); childrenDelta = (nodeMatches.get(childNode) || []).reduce( (reducedDelta, matcher) => { return matcher(childNode, reducedDelta, scroll); }, childrenDelta, ); } return delta.concat(childrenDelta); }, new Delta()); } return new Delta(); } function matchAlias(format, node, delta) { return applyFormat(delta, format, true); } function matchAttributor(node, delta, scroll) { const attributes = Attributor.keys(node); const classes = ClassAttributor.keys(node); const styles = StyleAttributor.keys(node); const formats = {}; attributes .concat(classes) .concat(styles) .forEach(name => { let attr = scroll.query(name, Scope.ATTRIBUTE); if (attr != null) { formats[attr.attrName] = attr.value(node); if (formats[attr.attrName]) return; } attr = ATTRIBUTE_ATTRIBUTORS[name]; if (attr != null && (attr.attrName === name || attr.keyName === name)) { formats[attr.attrName] = attr.value(node) || undefined; } attr = STYLE_ATTRIBUTORS[name]; if (attr != null && (attr.attrName === name || attr.keyName === name)) { attr = STYLE_ATTRIBUTORS[name]; formats[attr.attrName] = attr.value(node) || undefined; } }); if (Object.keys(formats).length > 0) { return applyFormat(delta, formats); } return delta; } function matchBlot(node, delta, scroll) { const match = scroll.query(node); if (match == null) return delta; if (match.prototype instanceof EmbedBlot) { const embed = {}; const value = match.value(node); if (value != null) { embed[match.blotName] = value; return new Delta().insert(embed, match.formats(node, scroll)); } } else { if (match.prototype instanceof BlockBlot && !deltaEndsWith(delta, '\n')) { delta.insert('\n'); } if (typeof match.formats === 'function') { return applyFormat(delta, match.blotName, match.formats(node, scroll)); } } return delta; } function matchBreak(node, delta) { if (!deltaEndsWith(delta, '\n')) { delta.insert('\n'); } return delta; } function matchCodeBlock(node, delta, scroll) { const match = scroll.query('code-block'); const language = match ? match.formats(node, scroll) : true; return applyFormat(delta, 'code-block', language); } function matchIgnore() { return new Delta(); } function matchIndent(node, delta, scroll) { const match = scroll.query(node); if ( match == null || match.blotName !== 'list' || !deltaEndsWith(delta, '\n') ) { return delta; } let indent = -1; let parent = node.parentNode; while (parent != null) { if (['OL', 'UL'].includes(parent.tagName)) { indent += 1; } parent = parent.parentNode; } if (indent <= 0) return delta; return delta.reduce((composed, op) => { if (op.attributes && op.attributes.list) { return composed.push(op); } return composed.insert(op.insert, { indent, ...(op.attributes || {}) }); }, new Delta()); } function matchList(node, delta) { const list = node.tagName === 'OL' ? 'ordered' : 'bullet'; return applyFormat(delta, 'list', list); } function matchNewline(node, delta, scroll) { if (!deltaEndsWith(delta, '\n')) { if (isLine(node)) { return delta.insert('\n'); } if (delta.length() > 0 && node.nextSibling) { let { nextSibling } = node; while (nextSibling != null) { if (isLine(nextSibling)) { return delta.insert('\n'); } const match = scroll.query(nextSibling); if (match && match.prototype instanceof BlockEmbed) { return delta.insert('\n'); } nextSibling = nextSibling.firstChild; } } } return delta; } function matchStyles(node, delta) { const formats = {}; const style = node.style || {}; if (style.fontStyle === 'italic') { formats.italic = true; } if ( style.fontWeight.startsWith('bold') || parseInt(style.fontWeight, 10) >= 700 ) { formats.bold = true; } if (Object.keys(formats).length > 0) { delta = applyFormat(delta, formats); } if (parseFloat(style.textIndent || 0) > 0) { // Could be 0.5in return new Delta().insert('\t').concat(delta); } return delta; } function matchTable(node, delta) { const table = node.parentNode.tagName === 'TABLE' ? node.parentNode : node.parentNode.parentNode; const rows = Array.from(table.querySelectorAll('tr')); const row = rows.indexOf(node) + 1; return applyFormat(delta, 'table', row); } function matchText(node, delta) { let text = node.data; // Word represents empty line with   if (node.parentNode.tagName === 'O:P') { return delta.insert(text.trim()); } if (text.trim().length === 0 && text.includes('\n')) { return delta; } if (!isPre(node)) { const replacer = (collapse, match) => { const replaced = match.replace(/[^\u00a0]/g, ''); // \u00a0 is nbsp; return replaced.length < 1 && collapse ? ' ' : replaced; }; text = text.replace(/\r\n/g, ' ').replace(/\n/g, ' '); text = text.replace(/\s\s+/g, replacer.bind(replacer, true)); // collapse whitespace if ( (node.previousSibling == null && isLine(node.parentNode)) || (node.previousSibling != null && isLine(node.previousSibling)) ) { text = text.replace(/^\s+/, replacer.bind(replacer, false)); } if ( (node.nextSibling == null && isLine(node.parentNode)) || (node.nextSibling != null && isLine(node.nextSibling)) ) { text = text.replace(/\s+$/, replacer.bind(replacer, false)); } } return delta.insert(text); } export { Clipboard as default, matchAttributor, matchBlot, matchNewline, matchText, traverse, };