line_data.js (13670B)
1 import { getOrder } from "../util/bidi.js" 2 import { ie, ie_version, webkit } from "../util/browser.js" 3 import { elt, eltP, joinClasses } from "../util/dom.js" 4 import { eventMixin, signal } from "../util/event.js" 5 import { hasBadBidiRects, zeroWidthElement } from "../util/feature_detection.js" 6 import { lst, spaceStr } from "../util/misc.js" 7 8 import { getLineStyles } from "./highlight.js" 9 import { attachMarkedSpans, compareCollapsedMarkers, detachMarkedSpans, lineIsHidden, visualLineContinued } from "./spans.js" 10 import { getLine, lineNo, updateLineHeight } from "./utils_line.js" 11 12 // LINE DATA STRUCTURE 13 14 // Line objects. These hold state related to a line, including 15 // highlighting info (the styles array). 16 export class Line { 17 constructor(text, markedSpans, estimateHeight) { 18 this.text = text 19 attachMarkedSpans(this, markedSpans) 20 this.height = estimateHeight ? estimateHeight(this) : 1 21 } 22 23 lineNo() { return lineNo(this) } 24 } 25 eventMixin(Line) 26 27 // Change the content (text, markers) of a line. Automatically 28 // invalidates cached information and tries to re-estimate the 29 // line's height. 30 export function updateLine(line, text, markedSpans, estimateHeight) { 31 line.text = text 32 if (line.stateAfter) line.stateAfter = null 33 if (line.styles) line.styles = null 34 if (line.order != null) line.order = null 35 detachMarkedSpans(line) 36 attachMarkedSpans(line, markedSpans) 37 let estHeight = estimateHeight ? estimateHeight(line) : 1 38 if (estHeight != line.height) updateLineHeight(line, estHeight) 39 } 40 41 // Detach a line from the document tree and its markers. 42 export function cleanUpLine(line) { 43 line.parent = null 44 detachMarkedSpans(line) 45 } 46 47 // Convert a style as returned by a mode (either null, or a string 48 // containing one or more styles) to a CSS style. This is cached, 49 // and also looks for line-wide styles. 50 let styleToClassCache = {}, styleToClassCacheWithMode = {} 51 function interpretTokenStyle(style, options) { 52 if (!style || /^\s*$/.test(style)) return null 53 let cache = options.addModeClass ? styleToClassCacheWithMode : styleToClassCache 54 return cache[style] || 55 (cache[style] = style.replace(/\S+/g, "cm-$&")) 56 } 57 58 // Render the DOM representation of the text of a line. Also builds 59 // up a 'line map', which points at the DOM nodes that represent 60 // specific stretches of text, and is used by the measuring code. 61 // The returned object contains the DOM node, this map, and 62 // information about line-wide styles that were set by the mode. 63 export function buildLineContent(cm, lineView) { 64 // The padding-right forces the element to have a 'border', which 65 // is needed on Webkit to be able to get line-level bounding 66 // rectangles for it (in measureChar). 67 let content = eltP("span", null, null, webkit ? "padding-right: .1px" : null) 68 let builder = {pre: eltP("pre", [content], "CodeMirror-line"), content: content, 69 col: 0, pos: 0, cm: cm, 70 trailingSpace: false, 71 splitSpaces: (ie || webkit) && cm.getOption("lineWrapping")} 72 lineView.measure = {} 73 74 // Iterate over the logical lines that make up this visual line. 75 for (let i = 0; i <= (lineView.rest ? lineView.rest.length : 0); i++) { 76 let line = i ? lineView.rest[i - 1] : lineView.line, order 77 builder.pos = 0 78 builder.addToken = buildToken 79 // Optionally wire in some hacks into the token-rendering 80 // algorithm, to deal with browser quirks. 81 if (hasBadBidiRects(cm.display.measure) && (order = getOrder(line, cm.doc.direction))) 82 builder.addToken = buildTokenBadBidi(builder.addToken, order) 83 builder.map = [] 84 let allowFrontierUpdate = lineView != cm.display.externalMeasured && lineNo(line) 85 insertLineContent(line, builder, getLineStyles(cm, line, allowFrontierUpdate)) 86 if (line.styleClasses) { 87 if (line.styleClasses.bgClass) 88 builder.bgClass = joinClasses(line.styleClasses.bgClass, builder.bgClass || "") 89 if (line.styleClasses.textClass) 90 builder.textClass = joinClasses(line.styleClasses.textClass, builder.textClass || "") 91 } 92 93 // Ensure at least a single node is present, for measuring. 94 if (builder.map.length == 0) 95 builder.map.push(0, 0, builder.content.appendChild(zeroWidthElement(cm.display.measure))) 96 97 // Store the map and a cache object for the current logical line 98 if (i == 0) { 99 lineView.measure.map = builder.map 100 lineView.measure.cache = {} 101 } else { 102 ;(lineView.measure.maps || (lineView.measure.maps = [])).push(builder.map) 103 ;(lineView.measure.caches || (lineView.measure.caches = [])).push({}) 104 } 105 } 106 107 // See issue #2901 108 if (webkit) { 109 let last = builder.content.lastChild 110 if (/\bcm-tab\b/.test(last.className) || (last.querySelector && last.querySelector(".cm-tab"))) 111 builder.content.className = "cm-tab-wrap-hack" 112 } 113 114 signal(cm, "renderLine", cm, lineView.line, builder.pre) 115 if (builder.pre.className) 116 builder.textClass = joinClasses(builder.pre.className, builder.textClass || "") 117 118 return builder 119 } 120 121 export function defaultSpecialCharPlaceholder(ch) { 122 let token = elt("span", "\u2022", "cm-invalidchar") 123 token.title = "\\u" + ch.charCodeAt(0).toString(16) 124 token.setAttribute("aria-label", token.title) 125 return token 126 } 127 128 // Build up the DOM representation for a single token, and add it to 129 // the line map. Takes care to render special characters separately. 130 function buildToken(builder, text, style, startStyle, endStyle, title, css) { 131 if (!text) return 132 let displayText = builder.splitSpaces ? splitSpaces(text, builder.trailingSpace) : text 133 let special = builder.cm.state.specialChars, mustWrap = false 134 let content 135 if (!special.test(text)) { 136 builder.col += text.length 137 content = document.createTextNode(displayText) 138 builder.map.push(builder.pos, builder.pos + text.length, content) 139 if (ie && ie_version < 9) mustWrap = true 140 builder.pos += text.length 141 } else { 142 content = document.createDocumentFragment() 143 let pos = 0 144 while (true) { 145 special.lastIndex = pos 146 let m = special.exec(text) 147 let skipped = m ? m.index - pos : text.length - pos 148 if (skipped) { 149 let txt = document.createTextNode(displayText.slice(pos, pos + skipped)) 150 if (ie && ie_version < 9) content.appendChild(elt("span", [txt])) 151 else content.appendChild(txt) 152 builder.map.push(builder.pos, builder.pos + skipped, txt) 153 builder.col += skipped 154 builder.pos += skipped 155 } 156 if (!m) break 157 pos += skipped + 1 158 let txt 159 if (m[0] == "\t") { 160 let tabSize = builder.cm.options.tabSize, tabWidth = tabSize - builder.col % tabSize 161 txt = content.appendChild(elt("span", spaceStr(tabWidth), "cm-tab")) 162 txt.setAttribute("role", "presentation") 163 txt.setAttribute("cm-text", "\t") 164 builder.col += tabWidth 165 } else if (m[0] == "\r" || m[0] == "\n") { 166 txt = content.appendChild(elt("span", m[0] == "\r" ? "\u240d" : "\u2424", "cm-invalidchar")) 167 txt.setAttribute("cm-text", m[0]) 168 builder.col += 1 169 } else { 170 txt = builder.cm.options.specialCharPlaceholder(m[0]) 171 txt.setAttribute("cm-text", m[0]) 172 if (ie && ie_version < 9) content.appendChild(elt("span", [txt])) 173 else content.appendChild(txt) 174 builder.col += 1 175 } 176 builder.map.push(builder.pos, builder.pos + 1, txt) 177 builder.pos++ 178 } 179 } 180 builder.trailingSpace = displayText.charCodeAt(text.length - 1) == 32 181 if (style || startStyle || endStyle || mustWrap || css) { 182 let fullStyle = style || "" 183 if (startStyle) fullStyle += startStyle 184 if (endStyle) fullStyle += endStyle 185 let token = elt("span", [content], fullStyle, css) 186 if (title) token.title = title 187 return builder.content.appendChild(token) 188 } 189 builder.content.appendChild(content) 190 } 191 192 function splitSpaces(text, trailingBefore) { 193 if (text.length > 1 && !/ /.test(text)) return text 194 let spaceBefore = trailingBefore, result = "" 195 for (let i = 0; i < text.length; i++) { 196 let ch = text.charAt(i) 197 if (ch == " " && spaceBefore && (i == text.length - 1 || text.charCodeAt(i + 1) == 32)) 198 ch = "\u00a0" 199 result += ch 200 spaceBefore = ch == " " 201 } 202 return result 203 } 204 205 // Work around nonsense dimensions being reported for stretches of 206 // right-to-left text. 207 function buildTokenBadBidi(inner, order) { 208 return (builder, text, style, startStyle, endStyle, title, css) => { 209 style = style ? style + " cm-force-border" : "cm-force-border" 210 let start = builder.pos, end = start + text.length 211 for (;;) { 212 // Find the part that overlaps with the start of this text 213 let part 214 for (let i = 0; i < order.length; i++) { 215 part = order[i] 216 if (part.to > start && part.from <= start) break 217 } 218 if (part.to >= end) return inner(builder, text, style, startStyle, endStyle, title, css) 219 inner(builder, text.slice(0, part.to - start), style, startStyle, null, title, css) 220 startStyle = null 221 text = text.slice(part.to - start) 222 start = part.to 223 } 224 } 225 } 226 227 function buildCollapsedSpan(builder, size, marker, ignoreWidget) { 228 let widget = !ignoreWidget && marker.widgetNode 229 if (widget) builder.map.push(builder.pos, builder.pos + size, widget) 230 if (!ignoreWidget && builder.cm.display.input.needsContentAttribute) { 231 if (!widget) 232 widget = builder.content.appendChild(document.createElement("span")) 233 widget.setAttribute("cm-marker", marker.id) 234 } 235 if (widget) { 236 builder.cm.display.input.setUneditable(widget) 237 builder.content.appendChild(widget) 238 } 239 builder.pos += size 240 builder.trailingSpace = false 241 } 242 243 // Outputs a number of spans to make up a line, taking highlighting 244 // and marked text into account. 245 function insertLineContent(line, builder, styles) { 246 let spans = line.markedSpans, allText = line.text, at = 0 247 if (!spans) { 248 for (let i = 1; i < styles.length; i+=2) 249 builder.addToken(builder, allText.slice(at, at = styles[i]), interpretTokenStyle(styles[i+1], builder.cm.options)) 250 return 251 } 252 253 let len = allText.length, pos = 0, i = 1, text = "", style, css 254 let nextChange = 0, spanStyle, spanEndStyle, spanStartStyle, title, collapsed 255 for (;;) { 256 if (nextChange == pos) { // Update current marker set 257 spanStyle = spanEndStyle = spanStartStyle = title = css = "" 258 collapsed = null; nextChange = Infinity 259 let foundBookmarks = [], endStyles 260 for (let j = 0; j < spans.length; ++j) { 261 let sp = spans[j], m = sp.marker 262 if (m.type == "bookmark" && sp.from == pos && m.widgetNode) { 263 foundBookmarks.push(m) 264 } else if (sp.from <= pos && (sp.to == null || sp.to > pos || m.collapsed && sp.to == pos && sp.from == pos)) { 265 if (sp.to != null && sp.to != pos && nextChange > sp.to) { 266 nextChange = sp.to 267 spanEndStyle = "" 268 } 269 if (m.className) spanStyle += " " + m.className 270 if (m.css) css = (css ? css + ";" : "") + m.css 271 if (m.startStyle && sp.from == pos) spanStartStyle += " " + m.startStyle 272 if (m.endStyle && sp.to == nextChange) (endStyles || (endStyles = [])).push(m.endStyle, sp.to) 273 if (m.title && !title) title = m.title 274 if (m.collapsed && (!collapsed || compareCollapsedMarkers(collapsed.marker, m) < 0)) 275 collapsed = sp 276 } else if (sp.from > pos && nextChange > sp.from) { 277 nextChange = sp.from 278 } 279 } 280 if (endStyles) for (let j = 0; j < endStyles.length; j += 2) 281 if (endStyles[j + 1] == nextChange) spanEndStyle += " " + endStyles[j] 282 283 if (!collapsed || collapsed.from == pos) for (let j = 0; j < foundBookmarks.length; ++j) 284 buildCollapsedSpan(builder, 0, foundBookmarks[j]) 285 if (collapsed && (collapsed.from || 0) == pos) { 286 buildCollapsedSpan(builder, (collapsed.to == null ? len + 1 : collapsed.to) - pos, 287 collapsed.marker, collapsed.from == null) 288 if (collapsed.to == null) return 289 if (collapsed.to == pos) collapsed = false 290 } 291 } 292 if (pos >= len) break 293 294 let upto = Math.min(len, nextChange) 295 while (true) { 296 if (text) { 297 let end = pos + text.length 298 if (!collapsed) { 299 let tokenText = end > upto ? text.slice(0, upto - pos) : text 300 builder.addToken(builder, tokenText, style ? style + spanStyle : spanStyle, 301 spanStartStyle, pos + tokenText.length == nextChange ? spanEndStyle : "", title, css) 302 } 303 if (end >= upto) {text = text.slice(upto - pos); pos = upto; break} 304 pos = end 305 spanStartStyle = "" 306 } 307 text = allText.slice(at, at = styles[i++]) 308 style = interpretTokenStyle(styles[i++], builder.cm.options) 309 } 310 } 311 } 312 313 314 // These objects are used to represent the visible (currently drawn) 315 // part of the document. A LineView may correspond to multiple 316 // logical lines, if those are connected by collapsed ranges. 317 export function LineView(doc, line, lineN) { 318 // The starting line 319 this.line = line 320 // Continuing lines, if any 321 this.rest = visualLineContinued(line) 322 // Number of logical lines in this visual line 323 this.size = this.rest ? lineNo(lst(this.rest)) - lineN + 1 : 1 324 this.node = this.text = null 325 this.hidden = lineIsHidden(doc, line) 326 } 327 328 // Create a range of LineView objects for the given lines. 329 export function buildViewArray(cm, from, to) { 330 let array = [], nextPos 331 for (let pos = from; pos < to; pos = nextPos) { 332 let view = new LineView(cm.doc, getLine(cm.doc, pos), pos) 333 nextPos = pos + view.size 334 array.push(view) 335 } 336 return array 337 }