359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
|
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 +\</g, '><'); // 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 <o:p> </o:p>
|
||
|
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 };
|