to_dom.ts (9865B)
1 import {Fragment} from "./fragment" 2 import {Node} from "./node" 3 import {Schema, NodeType, MarkType} from "./schema" 4 import {Mark} from "./mark" 5 import {DOMNode} from "./dom" 6 7 /// A description of a DOM structure. Can be either a string, which is 8 /// interpreted as a text node, a DOM node, which is interpreted as 9 /// itself, a `{dom, contentDOM}` object, or an array. 10 /// 11 /// An array describes a DOM element. The first value in the array 12 /// should be a string—the name of the DOM element, optionally prefixed 13 /// by a namespace URL and a space. If the second element is plain 14 /// object, it is interpreted as a set of attributes for the element. 15 /// Any elements after that (including the 2nd if it's not an attribute 16 /// object) are interpreted as children of the DOM elements, and must 17 /// either be valid `DOMOutputSpec` values, or the number zero. 18 /// 19 /// The number zero (pronounced “hole”) is used to indicate the place 20 /// where a node's child nodes should be inserted. If it occurs in an 21 /// output spec, it should be the only child element in its parent 22 /// node. 23 export type DOMOutputSpec = string | DOMNode | {dom: DOMNode, contentDOM?: HTMLElement} | readonly [string, ...any[]] 24 25 /// A DOM serializer knows how to convert ProseMirror nodes and 26 /// marks of various types to DOM nodes. 27 export class DOMSerializer { 28 /// Create a serializer. `nodes` should map node names to functions 29 /// that take a node and return a description of the corresponding 30 /// DOM. `marks` does the same for mark names, but also gets an 31 /// argument that tells it whether the mark's content is block or 32 /// inline content (for typical use, it'll always be inline). A mark 33 /// serializer may be `null` to indicate that marks of that type 34 /// should not be serialized. 35 constructor( 36 /// The node serialization functions. 37 readonly nodes: {[node: string]: (node: Node) => DOMOutputSpec}, 38 /// The mark serialization functions. 39 readonly marks: {[mark: string]: (mark: Mark, inline: boolean) => DOMOutputSpec} 40 ) {} 41 42 /// Serialize the content of this fragment to a DOM fragment. When 43 /// not in the browser, the `document` option, containing a DOM 44 /// document, should be passed so that the serializer can create 45 /// nodes. 46 serializeFragment(fragment: Fragment, options: {document?: Document} = {}, target?: HTMLElement | DocumentFragment) { 47 if (!target) target = doc(options).createDocumentFragment() 48 49 let top = target!, active: [Mark, HTMLElement | DocumentFragment][] = [] 50 fragment.forEach(node => { 51 if (active.length || node.marks.length) { 52 let keep = 0, rendered = 0 53 while (keep < active.length && rendered < node.marks.length) { 54 let next = node.marks[rendered] 55 if (!this.marks[next.type.name]) { rendered++; continue } 56 if (!next.eq(active[keep][0]) || next.type.spec.spanning === false) break 57 keep++; rendered++ 58 } 59 while (keep < active.length) top = active.pop()![1] 60 while (rendered < node.marks.length) { 61 let add = node.marks[rendered++] 62 let markDOM = this.serializeMark(add, node.isInline, options) 63 if (markDOM) { 64 active.push([add, top]) 65 top.appendChild(markDOM.dom) 66 top = markDOM.contentDOM || markDOM.dom as HTMLElement 67 } 68 } 69 } 70 top.appendChild(this.serializeNodeInner(node, options)) 71 }) 72 73 return target 74 } 75 76 /// @internal 77 serializeNodeInner(node: Node, options: {document?: Document}) { 78 let {dom, contentDOM} = 79 renderSpec(doc(options), this.nodes[node.type.name](node), null, node.attrs) 80 if (contentDOM) { 81 if (node.isLeaf) 82 throw new RangeError("Content hole not allowed in a leaf node spec") 83 this.serializeFragment(node.content, options, contentDOM) 84 } 85 return dom 86 } 87 88 /// Serialize this node to a DOM node. This can be useful when you 89 /// need to serialize a part of a document, as opposed to the whole 90 /// document. To serialize a whole document, use 91 /// [`serializeFragment`](#model.DOMSerializer.serializeFragment) on 92 /// its [content](#model.Node.content). 93 serializeNode(node: Node, options: {document?: Document} = {}) { 94 let dom = this.serializeNodeInner(node, options) 95 for (let i = node.marks.length - 1; i >= 0; i--) { 96 let wrap = this.serializeMark(node.marks[i], node.isInline, options) 97 if (wrap) { 98 ;(wrap.contentDOM || wrap.dom).appendChild(dom) 99 dom = wrap.dom 100 } 101 } 102 return dom 103 } 104 105 /// @internal 106 serializeMark(mark: Mark, inline: boolean, options: {document?: Document} = {}) { 107 let toDOM = this.marks[mark.type.name] 108 return toDOM && renderSpec(doc(options), toDOM(mark, inline), null, mark.attrs) 109 } 110 111 /// Render an [output spec](#model.DOMOutputSpec) to a DOM node. If 112 /// the spec has a hole (zero) in it, `contentDOM` will point at the 113 /// node with the hole. 114 static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS?: string | null): { 115 dom: DOMNode, 116 contentDOM?: HTMLElement 117 } 118 static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null = null, 119 blockArraysIn?: {[name: string]: any}): { 120 dom: DOMNode, 121 contentDOM?: HTMLElement 122 } { 123 return renderSpec(doc, structure, xmlNS, blockArraysIn) 124 } 125 126 /// Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) 127 /// properties in a schema's node and mark specs. 128 static fromSchema(schema: Schema): DOMSerializer { 129 return schema.cached.domSerializer as DOMSerializer || 130 (schema.cached.domSerializer = new DOMSerializer(this.nodesFromSchema(schema), this.marksFromSchema(schema))) 131 } 132 133 /// Gather the serializers in a schema's node specs into an object. 134 /// This can be useful as a base to build a custom serializer from. 135 static nodesFromSchema(schema: Schema) { 136 let result = gatherToDOM(schema.nodes) 137 if (!result.text) result.text = node => node.text 138 return result as {[node: string]: (node: Node) => DOMOutputSpec} 139 } 140 141 /// Gather the serializers in a schema's mark specs into an object. 142 static marksFromSchema(schema: Schema) { 143 return gatherToDOM(schema.marks) as {[mark: string]: (mark: Mark, inline: boolean) => DOMOutputSpec} 144 } 145 } 146 147 function gatherToDOM(obj: {[node: string]: NodeType | MarkType}) { 148 let result: {[node: string]: (value: any, inline: boolean) => DOMOutputSpec} = {} 149 for (let name in obj) { 150 let toDOM = obj[name].spec.toDOM 151 if (toDOM) result[name] = toDOM 152 } 153 return result 154 } 155 156 function doc(options: {document?: Document}) { 157 return options.document || window.document 158 } 159 160 const suspiciousAttributeCache = new WeakMap<any, readonly any[] | null>() 161 162 function suspiciousAttributes(attrs: {[name: string]: any}): readonly any[] | null { 163 let value = suspiciousAttributeCache.get(attrs) 164 if (value === undefined) 165 suspiciousAttributeCache.set(attrs, value = suspiciousAttributesInner(attrs)) 166 return value 167 } 168 169 function suspiciousAttributesInner(attrs: {[name: string]: any}): readonly any[] | null { 170 let result: any[] | null = null 171 function scan(value: any) { 172 if (value && typeof value == "object") { 173 if (Array.isArray(value)) { 174 if (typeof value[0] == "string") { 175 if (!result) result = [] 176 result.push(value) 177 } else { 178 for (let i = 0; i < value.length; i++) scan(value[i]) 179 } 180 } else { 181 for (let prop in value) scan(value[prop]) 182 } 183 } 184 } 185 scan(attrs) 186 return result 187 } 188 189 function renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null, 190 blockArraysIn?: {[name: string]: any}): { 191 dom: DOMNode, 192 contentDOM?: HTMLElement 193 } { 194 if (typeof structure == "string") 195 return {dom: doc.createTextNode(structure)} 196 if ((structure as DOMNode).nodeType != null) 197 return {dom: structure as DOMNode} 198 if ((structure as any).dom && (structure as any).dom.nodeType != null) 199 return structure as {dom: DOMNode, contentDOM?: HTMLElement} 200 let tagName = (structure as [string])[0], suspicious 201 if (typeof tagName != "string") throw new RangeError("Invalid array passed to renderSpec") 202 if (blockArraysIn && (suspicious = suspiciousAttributes(blockArraysIn)) && 203 suspicious.indexOf(structure) > -1) 204 throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.") 205 let space = tagName.indexOf(" ") 206 if (space > 0) { 207 xmlNS = tagName.slice(0, space) 208 tagName = tagName.slice(space + 1) 209 } 210 let contentDOM: HTMLElement | undefined 211 let dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)) as HTMLElement 212 let attrs = (structure as any)[1], start = 1 213 if (attrs && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs)) { 214 start = 2 215 for (let name in attrs) if (attrs[name] != null) { 216 let space = name.indexOf(" ") 217 if (space > 0) dom.setAttributeNS(name.slice(0, space), name.slice(space + 1), attrs[name]) 218 else if (name == "style" && dom.style) dom.style.cssText = attrs[name] 219 else dom.setAttribute(name, attrs[name]) 220 } 221 } 222 for (let i = start; i < (structure as readonly any[]).length; i++) { 223 let child = (structure as any)[i] as DOMOutputSpec | 0 224 if (child === 0) { 225 if (i < (structure as readonly any[]).length - 1 || i > start) 226 throw new RangeError("Content hole must be the only child of its parent node") 227 return {dom, contentDOM: dom} 228 } else { 229 let {dom: inner, contentDOM: innerContent} = renderSpec(doc, child, xmlNS, blockArraysIn) 230 dom.appendChild(inner) 231 if (innerContent) { 232 if (contentDOM) throw new RangeError("Multiple content holes") 233 contentDOM = innerContent as HTMLElement 234 } 235 } 236 } 237 return {dom, contentDOM} 238 }