UNPKG

11.7 kBJavaScriptView Raw
1import extend from 'extend';
2import Delta from 'quill-delta';
3import Parchment from 'parchment';
4import Quill from '../core/quill';
5import logger from '../core/logger';
6import Module from '../core/module';
7
8import { AlignAttribute, AlignStyle } from '../formats/align';
9import { BackgroundStyle } from '../formats/background';
10import CodeBlock from '../formats/code';
11import { ColorStyle } from '../formats/color';
12import { DirectionAttribute, DirectionStyle } from '../formats/direction';
13import { FontStyle } from '../formats/font';
14import { SizeStyle } from '../formats/size';
15
16let debug = logger('quill:clipboard');
17
18
19const DOM_KEY = '__ql-matcher';
20
21const CLIPBOARD_CONFIG = [
22 [Node.TEXT_NODE, matchText],
23 [Node.TEXT_NODE, matchNewline],
24 ['br', matchBreak],
25 [Node.ELEMENT_NODE, matchNewline],
26 [Node.ELEMENT_NODE, matchBlot],
27 [Node.ELEMENT_NODE, matchSpacing],
28 [Node.ELEMENT_NODE, matchAttributor],
29 [Node.ELEMENT_NODE, matchStyles],
30 ['li', matchIndent],
31 ['b', matchAlias.bind(matchAlias, 'bold')],
32 ['i', matchAlias.bind(matchAlias, 'italic')],
33 ['style', matchIgnore]
34];
35
36const ATTRIBUTE_ATTRIBUTORS = [
37 AlignAttribute,
38 DirectionAttribute
39].reduce(function(memo, attr) {
40 memo[attr.keyName] = attr;
41 return memo;
42}, {});
43
44const STYLE_ATTRIBUTORS = [
45 AlignStyle,
46 BackgroundStyle,
47 ColorStyle,
48 DirectionStyle,
49 FontStyle,
50 SizeStyle
51].reduce(function(memo, attr) {
52 memo[attr.keyName] = attr;
53 return memo;
54}, {});
55
56
57class Clipboard extends Module {
58 constructor(quill, options) {
59 super(quill, options);
60 this.quill.root.addEventListener('paste', this.onPaste.bind(this));
61 this.container = this.quill.addContainer('ql-clipboard');
62 this.container.setAttribute('contenteditable', true);
63 this.container.setAttribute('tabindex', -1);
64 this.matchers = [];
65 CLIPBOARD_CONFIG.concat(this.options.matchers).forEach(([selector, matcher]) => {
66 if (!options.matchVisual && matcher === matchSpacing) return;
67 this.addMatcher(selector, matcher);
68 });
69 }
70
71 addMatcher(selector, matcher) {
72 this.matchers.push([selector, matcher]);
73 }
74
75 convert(html) {
76 if (typeof html === 'string') {
77 this.container.innerHTML = html.replace(/\>\r?\n +\</g, '><'); // Remove spaces between tags
78 return this.convert();
79 }
80 const formats = this.quill.getFormat(this.quill.selection.savedRange.index);
81 if (formats[CodeBlock.blotName]) {
82 const text = this.container.innerText;
83 this.container.innerHTML = '';
84 return new Delta().insert(text, { [CodeBlock.blotName]: formats[CodeBlock.blotName] });
85 }
86 let [elementMatchers, textMatchers] = this.prepareMatching();
87 let delta = traverse(this.container, elementMatchers, textMatchers);
88 // Remove trailing newline
89 if (deltaEndsWith(delta, '\n') && delta.ops[delta.ops.length - 1].attributes == null) {
90 delta = delta.compose(new Delta().retain(delta.length() - 1).delete(1));
91 }
92 debug.log('convert', this.container.innerHTML, delta);
93 this.container.innerHTML = '';
94 return delta;
95 }
96
97 dangerouslyPasteHTML(index, html, source = Quill.sources.API) {
98 if (typeof index === 'string') {
99 return this.quill.setContents(this.convert(index), html);
100 } else {
101 let paste = this.convert(html);
102 return this.quill.updateContents(new Delta().retain(index).concat(paste), source);
103 }
104 }
105
106 onPaste(e) {
107 if (e.defaultPrevented || !this.quill.isEnabled()) return;
108 let range = this.quill.getSelection();
109 let delta = new Delta().retain(range.index);
110 let scrollTop = this.quill.scrollingContainer.scrollTop;
111 this.container.focus();
112 this.quill.selection.update(Quill.sources.SILENT);
113 setTimeout(() => {
114 delta = delta.concat(this.convert()).delete(range.length);
115 this.quill.updateContents(delta, Quill.sources.USER);
116 // range.length contributes to delta.length()
117 this.quill.setSelection(delta.length() - range.length, Quill.sources.SILENT);
118 this.quill.scrollingContainer.scrollTop = scrollTop;
119 this.quill.focus();
120 }, 1);
121 }
122
123 prepareMatching() {
124 let elementMatchers = [], textMatchers = [];
125 this.matchers.forEach((pair) => {
126 let [selector, matcher] = pair;
127 switch (selector) {
128 case Node.TEXT_NODE:
129 textMatchers.push(matcher);
130 break;
131 case Node.ELEMENT_NODE:
132 elementMatchers.push(matcher);
133 break;
134 default:
135 [].forEach.call(this.container.querySelectorAll(selector), (node) => {
136 // TODO use weakmap
137 node[DOM_KEY] = node[DOM_KEY] || [];
138 node[DOM_KEY].push(matcher);
139 });
140 break;
141 }
142 });
143 return [elementMatchers, textMatchers];
144 }
145}
146Clipboard.DEFAULTS = {
147 matchers: [],
148 matchVisual: true
149};
150
151
152function applyFormat(delta, format, value) {
153 if (typeof format === 'object') {
154 return Object.keys(format).reduce(function(delta, key) {
155 return applyFormat(delta, key, format[key]);
156 }, delta);
157 } else {
158 return delta.reduce(function(delta, op) {
159 if (op.attributes && op.attributes[format]) {
160 return delta.push(op);
161 } else {
162 return delta.insert(op.insert, extend({}, {[format]: value}, op.attributes));
163 }
164 }, new Delta());
165 }
166}
167
168function computeStyle(node) {
169 if (node.nodeType !== Node.ELEMENT_NODE) return {};
170 const DOM_KEY = '__ql-computed-style';
171 return node[DOM_KEY] || (node[DOM_KEY] = window.getComputedStyle(node));
172}
173
174function deltaEndsWith(delta, text) {
175 let endText = "";
176 for (let i = delta.ops.length - 1; i >= 0 && endText.length < text.length; --i) {
177 let op = delta.ops[i];
178 if (typeof op.insert !== 'string') break;
179 endText = op.insert + endText;
180 }
181 return endText.slice(-1*text.length) === text;
182}
183
184function isLine(node) {
185 if (node.childNodes.length === 0) return false; // Exclude embed blocks
186 let style = computeStyle(node);
187 return ['block', 'list-item'].indexOf(style.display) > -1;
188}
189
190function traverse(node, elementMatchers, textMatchers) { // Post-order
191 if (node.nodeType === node.TEXT_NODE) {
192 return textMatchers.reduce(function(delta, matcher) {
193 return matcher(node, delta);
194 }, new Delta());
195 } else if (node.nodeType === node.ELEMENT_NODE) {
196 return [].reduce.call(node.childNodes || [], (delta, childNode) => {
197 let childrenDelta = traverse(childNode, elementMatchers, textMatchers);
198 if (childNode.nodeType === node.ELEMENT_NODE) {
199 childrenDelta = elementMatchers.reduce(function(childrenDelta, matcher) {
200 return matcher(childNode, childrenDelta);
201 }, childrenDelta);
202 childrenDelta = (childNode[DOM_KEY] || []).reduce(function(childrenDelta, matcher) {
203 return matcher(childNode, childrenDelta);
204 }, childrenDelta);
205 }
206 return delta.concat(childrenDelta);
207 }, new Delta());
208 } else {
209 return new Delta();
210 }
211}
212
213
214function matchAlias(format, node, delta) {
215 return applyFormat(delta, format, true);
216}
217
218function matchAttributor(node, delta) {
219 let attributes = Parchment.Attributor.Attribute.keys(node);
220 let classes = Parchment.Attributor.Class.keys(node);
221 let styles = Parchment.Attributor.Style.keys(node);
222 let formats = {};
223 attributes.concat(classes).concat(styles).forEach((name) => {
224 let attr = Parchment.query(name, Parchment.Scope.ATTRIBUTE);
225 if (attr != null) {
226 formats[attr.attrName] = attr.value(node);
227 if (formats[attr.attrName]) return;
228 }
229 attr = ATTRIBUTE_ATTRIBUTORS[name];
230 if (attr != null && attr.attrName === name) {
231 formats[attr.attrName] = attr.value(node) || undefined;
232 }
233 attr = STYLE_ATTRIBUTORS[name]
234 if (attr != null && attr.attrName === name) {
235 attr = STYLE_ATTRIBUTORS[name];
236 formats[attr.attrName] = attr.value(node) || undefined;
237 }
238 });
239 if (Object.keys(formats).length > 0) {
240 delta = applyFormat(delta, formats);
241 }
242 return delta;
243}
244
245function matchBlot(node, delta) {
246 let match = Parchment.query(node);
247 if (match == null) return delta;
248 if (match.prototype instanceof Parchment.Embed) {
249 let embed = {};
250 let value = match.value(node);
251 if (value != null) {
252 embed[match.blotName] = value;
253 delta = new Delta().insert(embed, match.formats(node));
254 }
255 } else if (typeof match.formats === 'function') {
256 delta = applyFormat(delta, match.blotName, match.formats(node));
257 }
258 return delta;
259}
260
261function matchBreak(node, delta) {
262 if (!deltaEndsWith(delta, '\n')) {
263 delta.insert('\n');
264 }
265 return delta;
266}
267
268function matchIgnore() {
269 return new Delta();
270}
271
272function matchIndent(node, delta) {
273 let match = Parchment.query(node);
274 if (match == null || match.blotName !== 'list-item' || !deltaEndsWith(delta, '\n')) {
275 return delta;
276 }
277 let indent = -1, parent = node.parentNode;
278 while (!parent.classList.contains('ql-clipboard')) {
279 if ((Parchment.query(parent) || {}).blotName === 'list') {
280 indent += 1;
281 }
282 parent = parent.parentNode;
283 }
284 if (indent <= 0) return delta;
285 return delta.compose(new Delta().retain(delta.length() - 1).retain(1, { indent: indent}));
286}
287
288function matchNewline(node, delta) {
289 if (!deltaEndsWith(delta, '\n')) {
290 if (isLine(node) || (delta.length() > 0 && node.nextSibling && isLine(node.nextSibling))) {
291 delta.insert('\n');
292 }
293 }
294 return delta;
295}
296
297function matchSpacing(node, delta) {
298 if (isLine(node) && node.nextElementSibling != null && !deltaEndsWith(delta, '\n\n')) {
299 let nodeHeight = node.offsetHeight + parseFloat(computeStyle(node).marginTop) + parseFloat(computeStyle(node).marginBottom);
300 if (node.nextElementSibling.offsetTop > node.offsetTop + nodeHeight*1.5) {
301 delta.insert('\n');
302 }
303 }
304 return delta;
305}
306
307function matchStyles(node, delta) {
308 let formats = {};
309 let style = node.style || {};
310 if (style.fontStyle && computeStyle(node).fontStyle === 'italic') {
311 formats.italic = true;
312 }
313 if (style.fontWeight && (computeStyle(node).fontWeight.startsWith('bold') ||
314 parseInt(computeStyle(node).fontWeight) >= 700)) {
315 formats.bold = true;
316 }
317 if (Object.keys(formats).length > 0) {
318 delta = applyFormat(delta, formats);
319 }
320 if (parseFloat(style.textIndent || 0) > 0) { // Could be 0.5in
321 delta = new Delta().insert('\t').concat(delta);
322 }
323 return delta;
324}
325
326function matchText(node, delta) {
327 let text = node.data;
328 // Word represents empty line with <o:p>&nbsp;</o:p>
329 if (node.parentNode.tagName === 'O:P') {
330 return delta.insert(text.trim());
331 }
332 if (text.trim().length === 0 && node.parentNode.classList.contains('ql-clipboard')) {
333 return delta;
334 }
335 if (!computeStyle(node.parentNode).whiteSpace.startsWith('pre')) {
336 // eslint-disable-next-line func-style
337 let replacer = function(collapse, match) {
338 match = match.replace(/[^\u00a0]/g, ''); // \u00a0 is nbsp;
339 return match.length < 1 && collapse ? ' ' : match;
340 };
341 text = text.replace(/\r\n/g, ' ').replace(/\n/g, ' ');
342 text = text.replace(/\s\s+/g, replacer.bind(replacer, true)); // collapse whitespace
343 if ((node.previousSibling == null && isLine(node.parentNode)) ||
344 (node.previousSibling != null && isLine(node.previousSibling))) {
345 text = text.replace(/^\s+/, replacer.bind(replacer, false));
346 }
347 if ((node.nextSibling == null && isLine(node.parentNode)) ||
348 (node.nextSibling != null && isLine(node.nextSibling))) {
349 text = text.replace(/\s+$/, replacer.bind(replacer, false));
350 }
351 }
352 return delta.insert(text);
353}
354
355
356export { Clipboard as default, matchAttributor, matchBlot, matchNewline, matchSpacing, matchText };