clipboard.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. import Delta from 'quill-delta';
  2. import {
  3. Attributor,
  4. ClassAttributor,
  5. EmbedBlot,
  6. Scope,
  7. StyleAttributor,
  8. BlockBlot,
  9. } from 'parchment';
  10. import { BlockEmbed } from '../blots/block';
  11. import Quill from '../core/quill';
  12. import logger from '../core/logger';
  13. import Module from '../core/module';
  14. import { AlignAttribute, AlignStyle } from '../formats/align';
  15. import { BackgroundStyle } from '../formats/background';
  16. import CodeBlock from '../formats/code';
  17. import { ColorStyle } from '../formats/color';
  18. import { DirectionAttribute, DirectionStyle } from '../formats/direction';
  19. import { FontStyle } from '../formats/font';
  20. import { SizeStyle } from '../formats/size';
  21. const debug = logger('quill:clipboard');
  22. const CLIPBOARD_CONFIG = [
  23. [Node.TEXT_NODE, matchText],
  24. [Node.TEXT_NODE, matchNewline],
  25. ['br', matchBreak],
  26. [Node.ELEMENT_NODE, matchNewline],
  27. [Node.ELEMENT_NODE, matchBlot],
  28. [Node.ELEMENT_NODE, matchAttributor],
  29. [Node.ELEMENT_NODE, matchStyles],
  30. ['li', matchIndent],
  31. ['ol, ul', matchList],
  32. ['pre', matchCodeBlock],
  33. ['tr', matchTable],
  34. ['b', matchAlias.bind(matchAlias, 'bold')],
  35. ['i', matchAlias.bind(matchAlias, 'italic')],
  36. ['strike', matchAlias.bind(matchAlias, 'strike')],
  37. ['style', matchIgnore],
  38. ];
  39. const ATTRIBUTE_ATTRIBUTORS = [AlignAttribute, DirectionAttribute].reduce(
  40. (memo, attr) => {
  41. memo[attr.keyName] = attr;
  42. return memo;
  43. },
  44. {},
  45. );
  46. const STYLE_ATTRIBUTORS = [
  47. AlignStyle,
  48. BackgroundStyle,
  49. ColorStyle,
  50. DirectionStyle,
  51. FontStyle,
  52. SizeStyle,
  53. ].reduce((memo, attr) => {
  54. memo[attr.keyName] = attr;
  55. return memo;
  56. }, {});
  57. class Clipboard extends Module {
  58. constructor(quill, options) {
  59. super(quill, options);
  60. this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
  61. this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
  62. this.quill.root.addEventListener('paste', this.onCapturePaste.bind(this));
  63. this.matchers = [];
  64. CLIPBOARD_CONFIG.concat(this.options.matchers).forEach(
  65. ([selector, matcher]) => {
  66. this.addMatcher(selector, matcher);
  67. },
  68. );
  69. }
  70. addMatcher(selector, matcher) {
  71. this.matchers.push([selector, matcher]);
  72. }
  73. convert({ html, text }, formats = {}) {
  74. if (formats[CodeBlock.blotName]) {
  75. return new Delta().insert(text, {
  76. [CodeBlock.blotName]: formats[CodeBlock.blotName],
  77. });
  78. }
  79. if (!html) {
  80. return new Delta().insert(text || '');
  81. }
  82. const doc = new DOMParser().parseFromString(html, 'text/html');
  83. const container = doc.body;
  84. const nodeMatches = new WeakMap();
  85. const [elementMatchers, textMatchers] = this.prepareMatching(
  86. container,
  87. nodeMatches,
  88. );
  89. const delta = traverse(
  90. this.quill.scroll,
  91. container,
  92. elementMatchers,
  93. textMatchers,
  94. nodeMatches,
  95. );
  96. // Remove trailing newline
  97. if (
  98. deltaEndsWith(delta, '\n') &&
  99. (delta.ops[delta.ops.length - 1].attributes == null || formats.table)
  100. ) {
  101. return delta.compose(new Delta().retain(delta.length() - 1).delete(1));
  102. }
  103. return delta;
  104. }
  105. dangerouslyPasteHTML(index, html, source = Quill.sources.API) {
  106. if (typeof index === 'string') {
  107. const delta = this.convert({ html: index, text: '' });
  108. this.quill.setContents(delta, html);
  109. this.quill.setSelection(0, Quill.sources.SILENT);
  110. } else {
  111. const paste = this.convert({ html, text: '' });
  112. this.quill.updateContents(
  113. new Delta().retain(index).concat(paste),
  114. source,
  115. );
  116. this.quill.setSelection(index + paste.length(), Quill.sources.SILENT);
  117. }
  118. }
  119. onCaptureCopy(e, isCut = false) {
  120. if (e.defaultPrevented) return;
  121. e.preventDefault();
  122. const [range] = this.quill.selection.getRange();
  123. if (range == null) return;
  124. const { html, text } = this.onCopy(range, isCut);
  125. e.clipboardData.setData('text/plain', text);
  126. e.clipboardData.setData('text/html', html);
  127. if (isCut) {
  128. this.quill.deleteText(range, Quill.sources.USER);
  129. }
  130. }
  131. onCapturePaste(e) {
  132. if (e.defaultPrevented || !this.quill.isEnabled()) return;
  133. e.preventDefault();
  134. const range = this.quill.getSelection(true);
  135. if (range == null) return;
  136. const html = e.clipboardData.getData('text/html');
  137. const text = e.clipboardData.getData('text/plain');
  138. const files = Array.from(e.clipboardData.files || []);
  139. if (!html && files.length > 0) {
  140. this.quill.uploader.upload(range, files);
  141. } else {
  142. this.onPaste(range, { html, text });
  143. }
  144. }
  145. onCopy(range) {
  146. const text = this.quill.getText(range);
  147. const html = this.quill.getSemanticHTML(range);
  148. return { html, text };
  149. }
  150. onPaste(range, { text, html }) {
  151. const formats = this.quill.getFormat(range.index);
  152. const pastedDelta = this.convert({ text, html }, formats);
  153. debug.log('onPaste', pastedDelta, { text, html });
  154. const delta = new Delta()
  155. .retain(range.index)
  156. .delete(range.length)
  157. .concat(pastedDelta);
  158. this.quill.updateContents(delta, Quill.sources.USER);
  159. // range.length contributes to delta.length()
  160. this.quill.setSelection(
  161. delta.length() - range.length,
  162. Quill.sources.SILENT,
  163. );
  164. this.quill.scrollIntoView();
  165. }
  166. prepareMatching(container, nodeMatches) {
  167. const elementMatchers = [];
  168. const textMatchers = [];
  169. this.matchers.forEach(pair => {
  170. const [selector, matcher] = pair;
  171. switch (selector) {
  172. case Node.TEXT_NODE:
  173. textMatchers.push(matcher);
  174. break;
  175. case Node.ELEMENT_NODE:
  176. elementMatchers.push(matcher);
  177. break;
  178. default:
  179. Array.from(container.querySelectorAll(selector)).forEach(node => {
  180. if (nodeMatches.has(node)) {
  181. const matches = nodeMatches.get(node);
  182. matches.push(matcher);
  183. } else {
  184. nodeMatches.set(node, [matcher]);
  185. }
  186. });
  187. break;
  188. }
  189. });
  190. return [elementMatchers, textMatchers];
  191. }
  192. }
  193. Clipboard.DEFAULTS = {
  194. matchers: [],
  195. };
  196. function applyFormat(delta, format, value) {
  197. if (typeof format === 'object') {
  198. return Object.keys(format).reduce((newDelta, key) => {
  199. return applyFormat(newDelta, key, format[key]);
  200. }, delta);
  201. }
  202. return delta.reduce((newDelta, op) => {
  203. if (op.attributes && op.attributes[format]) {
  204. return newDelta.push(op);
  205. }
  206. const formats = value ? { [format]: value } : {};
  207. return newDelta.insert(op.insert, { ...formats, ...op.attributes });
  208. }, new Delta());
  209. }
  210. function deltaEndsWith(delta, text) {
  211. let endText = '';
  212. for (
  213. let i = delta.ops.length - 1;
  214. i >= 0 && endText.length < text.length;
  215. --i // eslint-disable-line no-plusplus
  216. ) {
  217. const op = delta.ops[i];
  218. if (typeof op.insert !== 'string') break;
  219. endText = op.insert + endText;
  220. }
  221. return endText.slice(-1 * text.length) === text;
  222. }
  223. function isLine(node) {
  224. if (node.childNodes.length === 0) return false; // Exclude embed blocks
  225. return [
  226. 'address',
  227. 'article',
  228. 'blockquote',
  229. 'canvas',
  230. 'dd',
  231. 'div',
  232. 'dl',
  233. 'dt',
  234. 'fieldset',
  235. 'figcaption',
  236. 'figure',
  237. 'footer',
  238. 'form',
  239. 'h1',
  240. 'h2',
  241. 'h3',
  242. 'h4',
  243. 'h5',
  244. 'h6',
  245. 'header',
  246. 'iframe',
  247. 'li',
  248. 'main',
  249. 'nav',
  250. 'ol',
  251. 'output',
  252. 'p',
  253. 'pre',
  254. 'section',
  255. 'table',
  256. 'td',
  257. 'tr',
  258. 'ul',
  259. 'video',
  260. ].includes(node.tagName.toLowerCase());
  261. }
  262. const preNodes = new WeakMap();
  263. function isPre(node) {
  264. if (node == null) return false;
  265. if (!preNodes.has(node)) {
  266. if (node.tagName === 'PRE') {
  267. preNodes.set(node, true);
  268. } else {
  269. preNodes.set(node, isPre(node.parentNode));
  270. }
  271. }
  272. return preNodes.get(node);
  273. }
  274. function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) {
  275. // Post-order
  276. if (node.nodeType === node.TEXT_NODE) {
  277. return textMatchers.reduce((delta, matcher) => {
  278. return matcher(node, delta, scroll);
  279. }, new Delta());
  280. }
  281. if (node.nodeType === node.ELEMENT_NODE) {
  282. return Array.from(node.childNodes || []).reduce((delta, childNode) => {
  283. let childrenDelta = traverse(
  284. scroll,
  285. childNode,
  286. elementMatchers,
  287. textMatchers,
  288. nodeMatches,
  289. );
  290. if (childNode.nodeType === node.ELEMENT_NODE) {
  291. childrenDelta = elementMatchers.reduce((reducedDelta, matcher) => {
  292. return matcher(childNode, reducedDelta, scroll);
  293. }, childrenDelta);
  294. childrenDelta = (nodeMatches.get(childNode) || []).reduce(
  295. (reducedDelta, matcher) => {
  296. return matcher(childNode, reducedDelta, scroll);
  297. },
  298. childrenDelta,
  299. );
  300. }
  301. return delta.concat(childrenDelta);
  302. }, new Delta());
  303. }
  304. return new Delta();
  305. }
  306. function matchAlias(format, node, delta) {
  307. return applyFormat(delta, format, true);
  308. }
  309. function matchAttributor(node, delta, scroll) {
  310. const attributes = Attributor.keys(node);
  311. const classes = ClassAttributor.keys(node);
  312. const styles = StyleAttributor.keys(node);
  313. const formats = {};
  314. attributes
  315. .concat(classes)
  316. .concat(styles)
  317. .forEach(name => {
  318. let attr = scroll.query(name, Scope.ATTRIBUTE);
  319. if (attr != null) {
  320. formats[attr.attrName] = attr.value(node);
  321. if (formats[attr.attrName]) return;
  322. }
  323. attr = ATTRIBUTE_ATTRIBUTORS[name];
  324. if (attr != null && (attr.attrName === name || attr.keyName === name)) {
  325. formats[attr.attrName] = attr.value(node) || undefined;
  326. }
  327. attr = STYLE_ATTRIBUTORS[name];
  328. if (attr != null && (attr.attrName === name || attr.keyName === name)) {
  329. attr = STYLE_ATTRIBUTORS[name];
  330. formats[attr.attrName] = attr.value(node) || undefined;
  331. }
  332. });
  333. if (Object.keys(formats).length > 0) {
  334. return applyFormat(delta, formats);
  335. }
  336. return delta;
  337. }
  338. function matchBlot(node, delta, scroll) {
  339. const match = scroll.query(node);
  340. if (match == null) return delta;
  341. if (match.prototype instanceof EmbedBlot) {
  342. const embed = {};
  343. const value = match.value(node);
  344. if (value != null) {
  345. embed[match.blotName] = value;
  346. return new Delta().insert(embed, match.formats(node, scroll));
  347. }
  348. } else {
  349. if (match.prototype instanceof BlockBlot && !deltaEndsWith(delta, '\n')) {
  350. delta.insert('\n');
  351. }
  352. if (typeof match.formats === 'function') {
  353. return applyFormat(delta, match.blotName, match.formats(node, scroll));
  354. }
  355. }
  356. return delta;
  357. }
  358. function matchBreak(node, delta) {
  359. if (!deltaEndsWith(delta, '\n')) {
  360. delta.insert('\n');
  361. }
  362. return delta;
  363. }
  364. function matchCodeBlock(node, delta, scroll) {
  365. const match = scroll.query('code-block');
  366. const language = match ? match.formats(node, scroll) : true;
  367. return applyFormat(delta, 'code-block', language);
  368. }
  369. function matchIgnore() {
  370. return new Delta();
  371. }
  372. function matchIndent(node, delta, scroll) {
  373. const match = scroll.query(node);
  374. if (
  375. match == null ||
  376. match.blotName !== 'list' ||
  377. !deltaEndsWith(delta, '\n')
  378. ) {
  379. return delta;
  380. }
  381. let indent = -1;
  382. let parent = node.parentNode;
  383. while (parent != null) {
  384. if (['OL', 'UL'].includes(parent.tagName)) {
  385. indent += 1;
  386. }
  387. parent = parent.parentNode;
  388. }
  389. if (indent <= 0) return delta;
  390. return delta.reduce((composed, op) => {
  391. if (op.attributes && typeof op.attributes.indent === 'number') {
  392. return composed.push(op);
  393. }
  394. return composed.insert(op.insert, { indent, ...(op.attributes || {}) });
  395. }, new Delta());
  396. }
  397. function matchList(node, delta) {
  398. const list = node.tagName === 'OL' ? 'ordered' : 'bullet';
  399. return applyFormat(delta, 'list', list);
  400. }
  401. function matchNewline(node, delta, scroll) {
  402. if (!deltaEndsWith(delta, '\n')) {
  403. if (isLine(node)) {
  404. return delta.insert('\n');
  405. }
  406. if (delta.length() > 0 && node.nextSibling) {
  407. let { nextSibling } = node;
  408. while (nextSibling != null) {
  409. if (isLine(nextSibling)) {
  410. return delta.insert('\n');
  411. }
  412. const match = scroll.query(nextSibling);
  413. if (match && match.prototype instanceof BlockEmbed) {
  414. return delta.insert('\n');
  415. }
  416. nextSibling = nextSibling.firstChild;
  417. }
  418. }
  419. }
  420. return delta;
  421. }
  422. function matchStyles(node, delta) {
  423. const formats = {};
  424. const style = node.style || {};
  425. if (style.fontStyle === 'italic') {
  426. formats.italic = true;
  427. }
  428. if (style.textDecoration === 'underline') {
  429. formats.underline = true;
  430. }
  431. if (style.textDecoration === 'line-through') {
  432. formats.strike = true;
  433. }
  434. if (
  435. style.fontWeight.startsWith('bold') ||
  436. parseInt(style.fontWeight, 10) >= 700
  437. ) {
  438. formats.bold = true;
  439. }
  440. if (Object.keys(formats).length > 0) {
  441. delta = applyFormat(delta, formats);
  442. }
  443. if (parseFloat(style.textIndent || 0) > 0) {
  444. // Could be 0.5in
  445. return new Delta().insert('\t').concat(delta);
  446. }
  447. return delta;
  448. }
  449. function matchTable(node, delta) {
  450. const table =
  451. node.parentNode.tagName === 'TABLE'
  452. ? node.parentNode
  453. : node.parentNode.parentNode;
  454. const rows = Array.from(table.querySelectorAll('tr'));
  455. const row = rows.indexOf(node) + 1;
  456. return applyFormat(delta, 'table', row);
  457. }
  458. function matchText(node, delta) {
  459. let text = node.data;
  460. // Word represents empty line with <o:p>&nbsp;</o:p>
  461. if (node.parentNode.tagName === 'O:P') {
  462. return delta.insert(text.trim());
  463. }
  464. if (text.trim().length === 0 && text.includes('\n')) {
  465. return delta;
  466. }
  467. if (!isPre(node)) {
  468. const replacer = (collapse, match) => {
  469. const replaced = match.replace(/[^\u00a0]/g, ''); // \u00a0 is nbsp;
  470. return replaced.length < 1 && collapse ? ' ' : replaced;
  471. };
  472. text = text.replace(/\r\n/g, ' ').replace(/\n/g, ' ');
  473. text = text.replace(/\s\s+/g, replacer.bind(replacer, true)); // collapse whitespace
  474. if (
  475. (node.previousSibling == null && isLine(node.parentNode)) ||
  476. (node.previousSibling != null && isLine(node.previousSibling))
  477. ) {
  478. text = text.replace(/^\s+/, replacer.bind(replacer, false));
  479. }
  480. if (
  481. (node.nextSibling == null && isLine(node.parentNode)) ||
  482. (node.nextSibling != null && isLine(node.nextSibling))
  483. ) {
  484. text = text.replace(/\s+$/, replacer.bind(replacer, false));
  485. }
  486. }
  487. return delta.insert(text);
  488. }
  489. export {
  490. Clipboard as default,
  491. matchAttributor,
  492. matchBlot,
  493. matchNewline,
  494. matchText,
  495. traverse,
  496. };