clipboard.js 12 KB

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