1 | import extend from 'extend';
|
2 | import Delta from 'quill-delta';
|
3 | import Parchment from 'parchment';
|
4 | import Quill from '../core/quill';
|
5 | import logger from '../core/logger';
|
6 | import Module from '../core/module';
|
7 |
|
8 | import { AlignAttribute, AlignStyle } from '../formats/align';
|
9 | import { BackgroundStyle } from '../formats/background';
|
10 | import CodeBlock from '../formats/code';
|
11 | import { ColorStyle } from '../formats/color';
|
12 | import { DirectionAttribute, DirectionStyle } from '../formats/direction';
|
13 | import { FontStyle } from '../formats/font';
|
14 | import { SizeStyle } from '../formats/size';
|
15 |
|
16 | let debug = logger('quill:clipboard');
|
17 |
|
18 |
|
19 | const DOM_KEY = '__ql-matcher';
|
20 |
|
21 | const 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 |
|
36 | const ATTRIBUTE_ATTRIBUTORS = [
|
37 | AlignAttribute,
|
38 | DirectionAttribute
|
39 | ].reduce(function(memo, attr) {
|
40 | memo[attr.keyName] = attr;
|
41 | return memo;
|
42 | }, {});
|
43 |
|
44 | const 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 |
|
57 | class 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, '><');
|
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 |
|
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 |
|
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 |
|
137 | node[DOM_KEY] = node[DOM_KEY] || [];
|
138 | node[DOM_KEY].push(matcher);
|
139 | });
|
140 | break;
|
141 | }
|
142 | });
|
143 | return [elementMatchers, textMatchers];
|
144 | }
|
145 | }
|
146 | Clipboard.DEFAULTS = {
|
147 | matchers: [],
|
148 | matchVisual: true
|
149 | };
|
150 |
|
151 |
|
152 | function 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 |
|
168 | function 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 |
|
174 | function 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 |
|
184 | function isLine(node) {
|
185 | if (node.childNodes.length === 0) return false;
|
186 | let style = computeStyle(node);
|
187 | return ['block', 'list-item'].indexOf(style.display) > -1;
|
188 | }
|
189 |
|
190 | function traverse(node, elementMatchers, textMatchers) {
|
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 |
|
214 | function matchAlias(format, node, delta) {
|
215 | return applyFormat(delta, format, true);
|
216 | }
|
217 |
|
218 | function 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 |
|
245 | function 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 |
|
261 | function matchBreak(node, delta) {
|
262 | if (!deltaEndsWith(delta, '\n')) {
|
263 | delta.insert('\n');
|
264 | }
|
265 | return delta;
|
266 | }
|
267 |
|
268 | function matchIgnore() {
|
269 | return new Delta();
|
270 | }
|
271 |
|
272 | function 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 |
|
288 | function 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 |
|
297 | function 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 |
|
307 | function 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) {
|
321 | delta = new Delta().insert('\t').concat(delta);
|
322 | }
|
323 | return delta;
|
324 | }
|
325 |
|
326 | function matchText(node, delta) {
|
327 | let text = node.data;
|
328 |
|
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 |
|
337 | let replacer = function(collapse, match) {
|
338 | match = match.replace(/[^\u00a0]/g, '');
|
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));
|
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 |
|
356 | export { Clipboard as default, matchAttributor, matchBlot, matchNewline, matchSpacing, matchText };
|