|
| 1 | +/* |
| 2 | + * Custom Clipboard module to override Quill's default clipboard behavior |
| 3 | + * to better handle pasting from various sources. |
| 4 | + * https://github.com/slab/quill/blob/main/packages/quill/src/modules/clipboard.ts |
| 5 | + */ |
| 6 | + |
| 7 | +import { EmbedBlot, type ScrollBlot } from "parchment"; |
1 | 8 | import Quill, { Delta } from "quill"; |
2 | | -import Clipboard from "quill/modules/clipboard"; |
| 9 | +import Clipboard, { matchNewline } from "quill/modules/clipboard"; |
| 10 | + |
| 11 | +function isLine(node: Node, scroll: ScrollBlot): boolean { |
| 12 | + if (!(node instanceof Element)) return false; |
| 13 | + const match = scroll.query(node); |
| 14 | + // @ts-expect-error prototype does not exist on Blot |
| 15 | + if (match && match.prototype instanceof EmbedBlot) return false; |
| 16 | + |
| 17 | + return [ |
| 18 | + "address", |
| 19 | + "article", |
| 20 | + "blockquote", |
| 21 | + "canvas", |
| 22 | + "dd", |
| 23 | + "div", |
| 24 | + "dl", |
| 25 | + "dt", |
| 26 | + "fieldset", |
| 27 | + "figcaption", |
| 28 | + "figure", |
| 29 | + "footer", |
| 30 | + "form", |
| 31 | + "h1", |
| 32 | + "h2", |
| 33 | + "h3", |
| 34 | + "h4", |
| 35 | + "h5", |
| 36 | + "h6", |
| 37 | + "header", |
| 38 | + "iframe", |
| 39 | + "li", |
| 40 | + "main", |
| 41 | + "nav", |
| 42 | + "ol", |
| 43 | + "output", |
| 44 | + "p", |
| 45 | + "pre", |
| 46 | + "section", |
| 47 | + "table", |
| 48 | + "td", |
| 49 | + "tr", |
| 50 | + "ul", |
| 51 | + "video" |
| 52 | + ].includes(node.tagName.toLowerCase()); |
| 53 | +} |
| 54 | + |
| 55 | +function isBetweenInlineElements(node: HTMLElement, scroll: ScrollBlot): boolean | null { |
| 56 | + return ( |
| 57 | + node.previousElementSibling && |
| 58 | + node.nextElementSibling && |
| 59 | + !isLine(node.previousElementSibling, scroll) && |
| 60 | + !isLine(node.nextElementSibling, scroll) |
| 61 | + ); |
| 62 | +} |
| 63 | + |
| 64 | +const preNodes = new WeakMap(); |
| 65 | +function isPre(node: Node | null): any { |
| 66 | + if (node == null) return false; |
| 67 | + if (!preNodes.has(node)) { |
| 68 | + // @ts-expect-error tagName does not exist on Node |
| 69 | + if (node.tagName === "PRE") { |
| 70 | + preNodes.set(node, true); |
| 71 | + } else { |
| 72 | + preNodes.set(node, isPre(node.parentNode)); |
| 73 | + } |
| 74 | + } |
| 75 | + return preNodes.get(node); |
| 76 | +} |
| 77 | + |
| 78 | +// overrides matchText from Quill's Clipboard module |
| 79 | +// removing text replacements that interfere with adding \t (tab) |
| 80 | +function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot): Delta { |
| 81 | + // @ts-expect-error data does not exist on HTMLElement |
| 82 | + let text = node.data as string; |
| 83 | + // Word represents empty line with <o:p> </o:p> |
| 84 | + if (node.parentElement?.tagName === "O:P") { |
| 85 | + return delta.insert(text.trim()); |
| 86 | + } |
| 87 | + if (!isPre(node)) { |
| 88 | + if (text.trim().length === 0 && text.includes("\n") && !isBetweenInlineElements(node, scroll)) { |
| 89 | + return delta; |
| 90 | + } |
| 91 | + text = text.replace(/ {2,}/g, " "); |
| 92 | + if ( |
| 93 | + (node.nextSibling == null && node.parentElement != null && isLine(node.parentElement, scroll)) || |
| 94 | + (node.nextSibling instanceof Element && isLine(node.nextSibling, scroll)) |
| 95 | + ) { |
| 96 | + // block structure means we don't need trailing space |
| 97 | + text = text.replace(/ $/, ""); |
| 98 | + } |
| 99 | + } |
| 100 | + return delta.insert(text); |
| 101 | +} |
| 102 | + |
| 103 | +function matchList(node: HTMLElement, delta: Delta, _scroll: ScrollBlot): Delta { |
| 104 | + const format = "list"; |
| 105 | + let list = "ordered"; |
| 106 | + const element = node as HTMLUListElement; |
| 107 | + const checkedAttr = element.getAttribute("data-checked"); |
| 108 | + if (checkedAttr) { |
| 109 | + list = checkedAttr === "true" ? "checked" : "unchecked"; |
| 110 | + } else { |
| 111 | + const listStyleType = element.style.listStyleType; |
| 112 | + if (listStyleType) { |
| 113 | + if (listStyleType === "disc") { |
| 114 | + // disc is standard list type, convert to bullet |
| 115 | + list = "bullet"; |
| 116 | + } else if (listStyleType === "decimal") { |
| 117 | + // list decimal type is dependant on indent level, convert to standard ordered list |
| 118 | + list = "ordered"; |
| 119 | + } else { |
| 120 | + list = listStyleType; |
| 121 | + } |
| 122 | + } else { |
| 123 | + list = element.tagName === "OL" ? "ordered" : "bullet"; |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + // apply list format to delta |
| 128 | + return delta.reduce((newDelta, op) => { |
| 129 | + if (!op.insert) return newDelta; |
| 130 | + if (op.attributes && op.attributes[format]) { |
| 131 | + return newDelta.push(op); |
| 132 | + } |
| 133 | + const formats = list ? { [format]: list } : {}; |
| 134 | + |
| 135 | + return newDelta.insert(op.insert, { ...formats, ...op.attributes }); |
| 136 | + }, new Delta()); |
| 137 | +} |
3 | 138 |
|
4 | 139 | export default class CustomClipboard extends Clipboard { |
5 | 140 | constructor(quill: Quill, options: any) { |
6 | 141 | super(quill, options); |
7 | 142 |
|
8 | 143 | // remove default list matchers for ol and ul |
9 | | - this.matchers = this.matchers.filter(matcher => matcher[0] !== "ol, ul"); |
10 | | - |
| 144 | + this.matchers = this.matchers.filter(matcher => matcher[0] !== "ol, ul" && matcher[0] !== Node.TEXT_NODE); |
| 145 | + this.addMatcher(Node.TEXT_NODE, matchNewline); |
| 146 | + this.addMatcher(Node.TEXT_NODE, matchText); |
11 | 147 | // add custom list matchers for ol and ul to allow custom list types (lower-alpha, lower-roman, etc.) |
12 | | - this.addMatcher("ol, ul", (node, delta) => { |
13 | | - const format = "list"; |
14 | | - let list = "ordered"; |
15 | | - const element = node as HTMLUListElement; |
16 | | - const checkedAttr = element.getAttribute("data-checked"); |
17 | | - if (checkedAttr) { |
18 | | - list = checkedAttr === "true" ? "checked" : "unchecked"; |
19 | | - } else { |
20 | | - const listStyleType = element.style.listStyleType; |
21 | | - if (listStyleType) { |
22 | | - if (listStyleType === "disc") { |
23 | | - // disc is standard list type, convert to bullet |
24 | | - list = "bullet"; |
25 | | - } else if (listStyleType === "decimal") { |
26 | | - // list decimal type is dependant on indent level, convert to standard ordered list |
27 | | - list = "ordered"; |
28 | | - } else { |
29 | | - list = listStyleType; |
30 | | - } |
31 | | - } else { |
32 | | - list = element.tagName === "OL" ? "ordered" : "bullet"; |
33 | | - } |
34 | | - } |
35 | | - |
36 | | - // apply list format to delta |
37 | | - return delta.reduce((newDelta, op) => { |
38 | | - if (!op.insert) return newDelta; |
39 | | - if (op.attributes && op.attributes[format]) { |
40 | | - return newDelta.push(op); |
41 | | - } |
42 | | - const formats = list ? { [format]: list } : {}; |
43 | | - |
44 | | - return newDelta.insert(op.insert, { ...formats, ...op.attributes }); |
45 | | - }, new Delta()); |
46 | | - }); |
| 148 | + this.addMatcher("ol, ul", matchList); |
47 | 149 | } |
48 | 150 | } |
0 commit comments