clipboard.ts (11724B)
1 import {Slice, Fragment, DOMParser, DOMSerializer, ResolvedPos, NodeType, Node} from "prosemirror-model" 2 import * as browser from "./browser" 3 import {EditorView} from "./index" 4 5 export function serializeForClipboard(view: EditorView, slice: Slice) { 6 view.someProp("transformCopied", f => { slice = f(slice!, view) }) 7 8 let context = [], {content, openStart, openEnd} = slice 9 while (openStart > 1 && openEnd > 1 && content.childCount == 1 && content.firstChild!.childCount == 1) { 10 openStart-- 11 openEnd-- 12 let node = content.firstChild! 13 context.push(node.type.name, node.attrs != node.type.defaultAttrs ? node.attrs : null) 14 content = node.content 15 } 16 17 let serializer = view.someProp("clipboardSerializer") || DOMSerializer.fromSchema(view.state.schema) 18 let doc = detachedDoc(), wrap = doc.createElement("div") 19 wrap.appendChild(serializer.serializeFragment(content, {document: doc})) 20 21 let firstChild = wrap.firstChild, needsWrap, wrappers = 0 22 while (firstChild && firstChild.nodeType == 1 && (needsWrap = wrapMap[firstChild.nodeName.toLowerCase()])) { 23 for (let i = needsWrap.length - 1; i >= 0; i--) { 24 let wrapper = doc.createElement(needsWrap[i]) 25 while (wrap.firstChild) wrapper.appendChild(wrap.firstChild) 26 wrap.appendChild(wrapper) 27 wrappers++ 28 } 29 firstChild = wrap.firstChild 30 } 31 32 if (firstChild && firstChild.nodeType == 1) 33 (firstChild as HTMLElement).setAttribute( 34 "data-pm-slice", `${openStart} ${openEnd}${wrappers ? ` -${wrappers}` : ""} ${JSON.stringify(context)}`) 35 36 let text = view.someProp("clipboardTextSerializer", f => f(slice, view)) || 37 slice.content.textBetween(0, slice.content.size, "\n\n") 38 39 return {dom: wrap, text, slice} 40 } 41 42 // Read a slice of content from the clipboard (or drop data). 43 export function parseFromClipboard(view: EditorView, text: string, html: string | null, plainText: boolean, $context: ResolvedPos) { 44 let inCode = $context.parent.type.spec.code 45 let dom: HTMLElement | undefined, slice: Slice | undefined 46 if (!html && !text) return null 47 let asText = !!text && (plainText || inCode || !html) 48 if (asText) { 49 view.someProp("transformPastedText", f => { text = f(text, inCode || plainText, view) }) 50 if (inCode) { 51 slice = new Slice(Fragment.from(view.state.schema.text(text.replace(/\r\n?/g, "\n"))), 0, 0) 52 view.someProp("transformPasted", f => { slice = f(slice!, view, true) }) 53 return slice 54 } 55 let parsed = view.someProp("clipboardTextParser", f => f(text, $context, plainText, view)) 56 if (parsed) { 57 slice = parsed 58 } else { 59 let marks = $context.marks() 60 let {schema} = view.state, serializer = DOMSerializer.fromSchema(schema) 61 dom = document.createElement("div") 62 text.split(/(?:\r\n?|\n)+/).forEach(block => { 63 let p = dom!.appendChild(document.createElement("p")) 64 if (block) p.appendChild(serializer.serializeNode(schema.text(block, marks))) 65 }) 66 } 67 } else { 68 view.someProp("transformPastedHTML", f => { html = f(html!, view) }) 69 dom = readHTML(html!) 70 if (browser.webkit) restoreReplacedSpaces(dom) 71 } 72 73 let contextNode = dom && dom.querySelector("[data-pm-slice]") 74 let sliceData = contextNode && /^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(contextNode.getAttribute("data-pm-slice") || "") 75 if (sliceData && sliceData[3]) for (let i = +sliceData[3]; i > 0; i--) { 76 let child = dom!.firstChild 77 while (child && child.nodeType != 1) child = child.nextSibling 78 if (!child) break 79 dom = child as HTMLElement 80 } 81 82 if (!slice) { 83 let parser = view.someProp("clipboardParser") || view.someProp("domParser") || DOMParser.fromSchema(view.state.schema) 84 slice = parser.parseSlice(dom!, { 85 preserveWhitespace: !!(asText || sliceData), 86 context: $context, 87 ruleFromNode(dom) { 88 if (dom.nodeName == "BR" && !dom.nextSibling && 89 dom.parentNode && !inlineParents.test(dom.parentNode.nodeName)) return {ignore: true} 90 return null 91 } 92 }) 93 } 94 if (sliceData) { 95 slice = addContext(closeSlice(slice, +sliceData[1], +sliceData[2]), sliceData[4]) 96 } else { // HTML wasn't created by ProseMirror. Make sure top-level siblings are coherent 97 slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true) 98 if (slice.openStart || slice.openEnd) { 99 let openStart = 0, openEnd = 0 100 for (let node = slice.content.firstChild; openStart < slice.openStart && !node!.type.spec.isolating; 101 openStart++, node = node!.firstChild) {} 102 for (let node = slice.content.lastChild; openEnd < slice.openEnd && !node!.type.spec.isolating; 103 openEnd++, node = node!.lastChild) {} 104 slice = closeSlice(slice, openStart, openEnd) 105 } 106 } 107 108 view.someProp("transformPasted", f => { slice = f(slice!, view, asText) }) 109 return slice 110 } 111 112 const inlineParents = /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i 113 114 // Takes a slice parsed with parseSlice, which means there hasn't been 115 // any content-expression checking done on the top nodes, tries to 116 // find a parent node in the current context that might fit the nodes, 117 // and if successful, rebuilds the slice so that it fits into that parent. 118 // 119 // This addresses the problem that Transform.replace expects a 120 // coherent slice, and will fail to place a set of siblings that don't 121 // fit anywhere in the schema. 122 function normalizeSiblings(fragment: Fragment, $context: ResolvedPos) { 123 if (fragment.childCount < 2) return fragment 124 for (let d = $context.depth; d >= 0; d--) { 125 let parent = $context.node(d) 126 let match = parent.contentMatchAt($context.index(d)) 127 let lastWrap: readonly NodeType[] | undefined, result: Node[] | null = [] 128 fragment.forEach(node => { 129 if (!result) return 130 let wrap = match.findWrapping(node.type), inLast 131 if (!wrap) return result = null 132 if (inLast = result.length && lastWrap!.length && addToSibling(wrap, lastWrap!, node, result[result.length - 1], 0)) { 133 result[result.length - 1] = inLast 134 } else { 135 if (result.length) result[result.length - 1] = closeRight(result[result.length - 1], lastWrap!.length) 136 let wrapped = withWrappers(node, wrap) 137 result.push(wrapped) 138 match = match.matchType(wrapped.type)! 139 lastWrap = wrap 140 } 141 }) 142 if (result) return Fragment.from(result) 143 } 144 return fragment 145 } 146 147 function withWrappers(node: Node, wrap: readonly NodeType[], from = 0) { 148 for (let i = wrap.length - 1; i >= from; i--) 149 node = wrap[i].create(null, Fragment.from(node)) 150 return node 151 } 152 153 // Used to group adjacent nodes wrapped in similar parents by 154 // normalizeSiblings into the same parent node 155 function addToSibling(wrap: readonly NodeType[], lastWrap: readonly NodeType[], 156 node: Node, sibling: Node, depth: number): Node | undefined { 157 if (depth < wrap.length && depth < lastWrap.length && wrap[depth] == lastWrap[depth]) { 158 let inner = addToSibling(wrap, lastWrap, node, sibling.lastChild!, depth + 1) 159 if (inner) return sibling.copy(sibling.content.replaceChild(sibling.childCount - 1, inner)) 160 let match = sibling.contentMatchAt(sibling.childCount) 161 if (match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1])) 162 return sibling.copy(sibling.content.append(Fragment.from(withWrappers(node, wrap, depth + 1)))) 163 } 164 } 165 166 function closeRight(node: Node, depth: number) { 167 if (depth == 0) return node 168 let fragment = node.content.replaceChild(node.childCount - 1, closeRight(node.lastChild!, depth - 1)) 169 let fill = node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true)! 170 return node.copy(fragment.append(fill)) 171 } 172 173 function closeRange(fragment: Fragment, side: number, from: number, to: number, depth: number, openEnd: number) { 174 let node = side < 0 ? fragment.firstChild! : fragment.lastChild!, inner = node.content 175 if (fragment.childCount > 1) openEnd = 0 176 if (depth < to - 1) inner = closeRange(inner, side, from, to, depth + 1, openEnd) 177 if (depth >= from) 178 inner = side < 0 ? node.contentMatchAt(0)!.fillBefore(inner, openEnd <= depth)!.append(inner) 179 : inner.append(node.contentMatchAt(node.childCount)!.fillBefore(Fragment.empty, true)!) 180 return fragment.replaceChild(side < 0 ? 0 : fragment.childCount - 1, node.copy(inner)) 181 } 182 183 function closeSlice(slice: Slice, openStart: number, openEnd: number) { 184 if (openStart < slice.openStart) 185 slice = new Slice(closeRange(slice.content, -1, openStart, slice.openStart, 0, slice.openEnd), openStart, slice.openEnd) 186 if (openEnd < slice.openEnd) 187 slice = new Slice(closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0), slice.openStart, openEnd) 188 return slice 189 } 190 191 // Trick from jQuery -- some elements must be wrapped in other 192 // elements for innerHTML to work. I.e. if you do `div.innerHTML = 193 // "<td>..</td>"` the table cells are ignored. 194 const wrapMap: {[node: string]: string[]} = { 195 thead: ["table"], 196 tbody: ["table"], 197 tfoot: ["table"], 198 caption: ["table"], 199 colgroup: ["table"], 200 col: ["table", "colgroup"], 201 tr: ["table", "tbody"], 202 td: ["table", "tbody", "tr"], 203 th: ["table", "tbody", "tr"] 204 } 205 206 let _detachedDoc: Document | null = null 207 function detachedDoc() { 208 return _detachedDoc || (_detachedDoc = document.implementation.createHTMLDocument("title")) 209 } 210 211 let _policy: any = null 212 213 function maybeWrapTrusted(html: string): string { 214 let trustedTypes = (window as any).trustedTypes 215 if (!trustedTypes) return html 216 // With the require-trusted-types-for CSP, Chrome will block 217 // innerHTML, even on a detached document. This wraps the string in 218 // a way that makes the browser allow us to use its parser again. 219 if (!_policy) 220 _policy = trustedTypes.defaultPolicy || trustedTypes.createPolicy("ProseMirrorClipboard", {createHTML: (s: string) => s}) 221 return _policy.createHTML(html) 222 } 223 224 function readHTML(html: string) { 225 let metas = /^(\s*<meta [^>]*>)*/.exec(html) 226 if (metas) html = html.slice(metas[0].length) 227 let elt = detachedDoc().createElement("div") 228 let firstTag = /<([a-z][^>\s]+)/i.exec(html), wrap 229 if (wrap = firstTag && wrapMap[firstTag[1].toLowerCase()]) 230 html = wrap.map(n => "<" + n + ">").join("") + html + wrap.map(n => "</" + n + ">").reverse().join("") 231 elt.innerHTML = maybeWrapTrusted(html) 232 if (wrap) for (let i = 0; i < wrap.length; i++) elt = elt.querySelector(wrap[i]) || elt 233 return elt 234 } 235 236 // Webkit browsers do some hard-to-predict replacement of regular 237 // spaces with non-breaking spaces when putting content on the 238 // clipboard. This tries to convert such non-breaking spaces (which 239 // will be wrapped in a plain span on Chrome, a span with class 240 // Apple-converted-space on Safari) back to regular spaces. 241 function restoreReplacedSpaces(dom: HTMLElement) { 242 let nodes = dom.querySelectorAll(browser.chrome ? "span:not([class]):not([style])" : "span.Apple-converted-space") 243 for (let i = 0; i < nodes.length; i++) { 244 let node = nodes[i] 245 if (node.childNodes.length == 1 && node.textContent == "\u00a0" && node.parentNode) 246 node.parentNode.replaceChild(dom.ownerDocument.createTextNode(" "), node) 247 } 248 } 249 250 function addContext(slice: Slice, context: string) { 251 if (!slice.size) return slice 252 let schema = slice.content.firstChild!.type.schema, array 253 try { array = JSON.parse(context) } 254 catch(e) { return slice } 255 let {content, openStart, openEnd} = slice 256 for (let i = array.length - 2; i >= 0; i -= 2) { 257 let type = schema.nodes[array[i]] 258 if (!type || type.hasRequiredAttrs()) break 259 content = Fragment.from(type.create(array[i + 1], content)) 260 openStart++; openEnd++ 261 } 262 return new Slice(content, openStart, openEnd) 263 }