import extend from 'extend'; import Delta from 'quill-delta'; import Parchment from 'parchment'; 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'; let debug = logger('quill:clipboard'); const DOM_KEY = '__ql-matcher'; const CLIPBOARD_CONFIG = [ [Node.TEXT_NODE, matchText], [Node.TEXT_NODE, matchNewline], ['br', matchBreak], [Node.ELEMENT_NODE, matchNewline], [Node.ELEMENT_NODE, matchBlot], [Node.ELEMENT_NODE, matchSpacing], [Node.ELEMENT_NODE, matchAttributor], [Node.ELEMENT_NODE, matchStyles], ['li', matchIndent], ['b', matchAlias.bind(matchAlias, 'bold')], ['i', matchAlias.bind(matchAlias, 'italic')], ['style', matchIgnore] ]; const ATTRIBUTE_ATTRIBUTORS = [ AlignAttribute, DirectionAttribute ].reduce(function(memo, attr) { memo[attr.keyName] = attr; return memo; }, {}); const STYLE_ATTRIBUTORS = [ AlignStyle, BackgroundStyle, ColorStyle, DirectionStyle, FontStyle, SizeStyle ].reduce(function(memo, attr) { memo[attr.keyName] = attr; return memo; }, {}); class Clipboard extends Module { constructor(quill, options) { super(quill, options); this.quill.root.addEventListener('paste', this.onPaste.bind(this)); this.container = this.quill.addContainer('ql-clipboard'); this.container.setAttribute('contenteditable', true); this.container.setAttribute('tabindex', -1); this.matchers = []; CLIPBOARD_CONFIG.concat(this.options.matchers).forEach(([selector, matcher]) => { if (!options.matchVisual && matcher === matchSpacing) return; this.addMatcher(selector, matcher); }); } addMatcher(selector, matcher) { this.matchers.push([selector, matcher]); } convert(html) { if (typeof html === 'string') { this.container.innerHTML = html.replace(/\>\r?\n +\<'); // Remove spaces between tags return this.convert(); } const formats = this.quill.getFormat(this.quill.selection.savedRange.index); if (formats[CodeBlock.blotName]) { const text = this.container.innerText; this.container.innerHTML = ''; return new Delta().insert(text, { [CodeBlock.blotName]: formats[CodeBlock.blotName] }); } let [elementMatchers, textMatchers] = this.prepareMatching(); let delta = traverse(this.container, elementMatchers, textMatchers); // Remove trailing newline if (deltaEndsWith(delta, '\n') && delta.ops[delta.ops.length - 1].attributes == null) { delta = delta.compose(new Delta().retain(delta.length() - 1).delete(1)); } debug.log('convert', this.container.innerHTML, delta); this.container.innerHTML = ''; return delta; } dangerouslyPasteHTML(index, html, source = Quill.sources.API) { if (typeof index === 'string') { this.quill.setContents(this.convert(index), html); this.quill.setSelection(0, Quill.sources.SILENT); } else { let paste = this.convert(html); this.quill.updateContents(new Delta().retain(index).concat(paste), source); this.quill.setSelection(index + paste.length(), Quill.sources.SILENT); } } onPaste(e) { if (e.defaultPrevented || !this.quill.isEnabled()) return; let range = this.quill.getSelection(); let delta = new Delta().retain(range.index); let scrollTop = this.quill.scrollingContainer.scrollTop; this.container.focus(); this.quill.selection.update(Quill.sources.SILENT); setTimeout(() => { delta = delta.concat(this.convert()).delete(range.length); 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.scrollingContainer.scrollTop = scrollTop; this.quill.focus(); }, 1); } prepareMatching() { let elementMatchers = [], textMatchers = []; this.matchers.forEach((pair) => { let [selector, matcher] = pair; switch (selector) { case Node.TEXT_NODE: textMatchers.push(matcher); break; case Node.ELEMENT_NODE: elementMatchers.push(matcher); break; default: [].forEach.call(this.container.querySelectorAll(selector), (node) => { // TODO use weakmap node[DOM_KEY] = node[DOM_KEY] || []; node[DOM_KEY].push(matcher); }); break; } }); return [elementMatchers, textMatchers]; } } Clipboard.DEFAULTS = { matchers: [], matchVisual: true }; function applyFormat(delta, format, value) { if (typeof format === 'object') { return Object.keys(format).reduce(function(delta, key) { return applyFormat(delta, key, format[key]); }, delta); } else { return delta.reduce(function(delta, op) { if (op.attributes && op.attributes[format]) { return delta.push(op); } else { return delta.insert(op.insert, extend({}, {[format]: value}, op.attributes)); } }, new Delta()); } } function computeStyle(node) { if (node.nodeType !== Node.ELEMENT_NODE) return {}; const DOM_KEY = '__ql-computed-style'; return node[DOM_KEY] || (node[DOM_KEY] = window.getComputedStyle(node)); } function deltaEndsWith(delta, text) { let endText = ""; for (let i = delta.ops.length - 1; i >= 0 && endText.length < text.length; --i) { let 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 let style = computeStyle(node); return ['block', 'list-item'].indexOf(style.display) > -1; } function traverse(node, elementMatchers, textMatchers) { // Post-order if (node.nodeType === node.TEXT_NODE) { return textMatchers.reduce(function(delta, matcher) { return matcher(node, delta); }, new Delta()); } else if (node.nodeType === node.ELEMENT_NODE) { return [].reduce.call(node.childNodes || [], (delta, childNode) => { let childrenDelta = traverse(childNode, elementMatchers, textMatchers); if (childNode.nodeType === node.ELEMENT_NODE) { childrenDelta = elementMatchers.reduce(function(childrenDelta, matcher) { return matcher(childNode, childrenDelta); }, childrenDelta); childrenDelta = (childNode[DOM_KEY] || []).reduce(function(childrenDelta, matcher) { return matcher(childNode, childrenDelta); }, childrenDelta); } return delta.concat(childrenDelta); }, new Delta()); } else { return new Delta(); } } function matchAlias(format, node, delta) { return applyFormat(delta, format, true); } function matchAttributor(node, delta) { let attributes = Parchment.Attributor.Attribute.keys(node); let classes = Parchment.Attributor.Class.keys(node); let styles = Parchment.Attributor.Style.keys(node); let formats = {}; attributes.concat(classes).concat(styles).forEach((name) => { let attr = Parchment.query(name, Parchment.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) { delta = applyFormat(delta, formats); } return delta; } function matchBlot(node, delta) { let match = Parchment.query(node); if (match == null) return delta; if (match.prototype instanceof Parchment.Embed) { let embed = {}; let value = match.value(node); if (value != null) { embed[match.blotName] = value; delta = new Delta().insert(embed, match.formats(node)); } } else if (typeof match.formats === 'function') { delta = applyFormat(delta, match.blotName, match.formats(node)); } return delta; } function matchBreak(node, delta) { if (!deltaEndsWith(delta, '\n')) { delta.insert('\n'); } return delta; } function matchIgnore() { return new Delta(); } function matchIndent(node, delta) { let match = Parchment.query(node); if (match == null || match.blotName !== 'list-item' || !deltaEndsWith(delta, '\n')) { return delta; } let indent = -1, parent = node.parentNode; while (!parent.classList.contains('ql-clipboard')) { if ((Parchment.query(parent) || {}).blotName === 'list') { indent += 1; } parent = parent.parentNode; } if (indent <= 0) return delta; return delta.compose(new Delta().retain(delta.length() - 1).retain(1, { indent: indent})); } function matchNewline(node, delta) { if (!deltaEndsWith(delta, '\n')) { if (isLine(node) || (delta.length() > 0 && node.nextSibling && isLine(node.nextSibling))) { delta.insert('\n'); } } return delta; } function matchSpacing(node, delta) { if (isLine(node) && node.nextElementSibling != null && !deltaEndsWith(delta, '\n\n')) { let nodeHeight = node.offsetHeight + parseFloat(computeStyle(node).marginTop) + parseFloat(computeStyle(node).marginBottom); if (node.nextElementSibling.offsetTop > node.offsetTop + nodeHeight*1.5) { delta.insert('\n'); } } return delta; } function matchStyles(node, delta) { let formats = {}; let style = node.style || {}; if (style.fontStyle && computeStyle(node).fontStyle === 'italic') { formats.italic = true; } if (style.fontWeight && (computeStyle(node).fontWeight.startsWith('bold') || parseInt(computeStyle(node).fontWeight) >= 700)) { formats.bold = true; } if (Object.keys(formats).length > 0) { delta = applyFormat(delta, formats); } if (parseFloat(style.textIndent || 0) > 0) { // Could be 0.5in delta = new Delta().insert('\t').concat(delta); } return delta; } 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 && node.parentNode.classList.contains('ql-clipboard')) { return delta; } if (!computeStyle(node.parentNode).whiteSpace.startsWith('pre')) { // eslint-disable-next-line func-style let replacer = function(collapse, match) { match = match.replace(/[^\u00a0]/g, ''); // \u00a0 is nbsp; return match.length < 1 && collapse ? ' ' : match; }; 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, matchSpacing, matchText };