350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
|
import { getOrder } from "../util/bidi.js"
|
||
|
import { ie, ie_version, webkit } from "../util/browser.js"
|
||
|
import { elt, eltP, joinClasses } from "../util/dom.js"
|
||
|
import { eventMixin, signal } from "../util/event.js"
|
||
|
import { hasBadBidiRects, zeroWidthElement } from "../util/feature_detection.js"
|
||
|
import { lst, spaceStr } from "../util/misc.js"
|
||
|
|
||
|
import { getLineStyles } from "./highlight.js"
|
||
|
import { attachMarkedSpans, compareCollapsedMarkers, detachMarkedSpans, lineIsHidden, visualLineContinued } from "./spans.js"
|
||
|
import { getLine, lineNo, updateLineHeight } from "./utils_line.js"
|
||
|
|
||
|
// LINE DATA STRUCTURE
|
||
|
|
||
|
// Line objects. These hold state related to a line, including
|
||
|
// highlighting info (the styles array).
|
||
|
export class Line {
|
||
|
constructor(text, markedSpans, estimateHeight) {
|
||
|
this.text = text
|
||
|
attachMarkedSpans(this, markedSpans)
|
||
|
this.height = estimateHeight ? estimateHeight(this) : 1
|
||
|
}
|
||
|
|
||
|
lineNo() { return lineNo(this) }
|
||
|
}
|
||
|
eventMixin(Line)
|
||
|
|
||
|
// Change the content (text, markers) of a line. Automatically
|
||
|
// invalidates cached information and tries to re-estimate the
|
||
|
// line's height.
|
||
|
export function updateLine(line, text, markedSpans, estimateHeight) {
|
||
|
line.text = text
|
||
|
if (line.stateAfter) line.stateAfter = null
|
||
|
if (line.styles) line.styles = null
|
||
|
if (line.order != null) line.order = null
|
||
|
detachMarkedSpans(line)
|
||
|
attachMarkedSpans(line, markedSpans)
|
||
|
let estHeight = estimateHeight ? estimateHeight(line) : 1
|
||
|
if (estHeight != line.height) updateLineHeight(line, estHeight)
|
||
|
}
|
||
|
|
||
|
// Detach a line from the document tree and its markers.
|
||
|
export function cleanUpLine(line) {
|
||
|
line.parent = null
|
||
|
detachMarkedSpans(line)
|
||
|
}
|
||
|
|
||
|
// Convert a style as returned by a mode (either null, or a string
|
||
|
// containing one or more styles) to a CSS style. This is cached,
|
||
|
// and also looks for line-wide styles.
|
||
|
let styleToClassCache = {}, styleToClassCacheWithMode = {}
|
||
|
function interpretTokenStyle(style, options) {
|
||
|
if (!style || /^\s*$/.test(style)) return null
|
||
|
let cache = options.addModeClass ? styleToClassCacheWithMode : styleToClassCache
|
||
|
return cache[style] ||
|
||
|
(cache[style] = style.replace(/\S+/g, "cm-$&"))
|
||
|
}
|
||
|
|
||
|
// Render the DOM representation of the text of a line. Also builds
|
||
|
// up a 'line map', which points at the DOM nodes that represent
|
||
|
// specific stretches of text, and is used by the measuring code.
|
||
|
// The returned object contains the DOM node, this map, and
|
||
|
// information about line-wide styles that were set by the mode.
|
||
|
export function buildLineContent(cm, lineView) {
|
||
|
// The padding-right forces the element to have a 'border', which
|
||
|
// is needed on Webkit to be able to get line-level bounding
|
||
|
// rectangles for it (in measureChar).
|
||
|
let content = eltP("span", null, null, webkit ? "padding-right: .1px" : null)
|
||
|
let builder = {pre: eltP("pre", [content], "CodeMirror-line"), content: content,
|
||
|
col: 0, pos: 0, cm: cm,
|
||
|
trailingSpace: false,
|
||
|
splitSpaces: cm.getOption("lineWrapping")}
|
||
|
lineView.measure = {}
|
||
|
|
||
|
// Iterate over the logical lines that make up this visual line.
|
||
|
for (let i = 0; i <= (lineView.rest ? lineView.rest.length : 0); i++) {
|
||
|
let line = i ? lineView.rest[i - 1] : lineView.line, order
|
||
|
builder.pos = 0
|
||
|
builder.addToken = buildToken
|
||
|
// Optionally wire in some hacks into the token-rendering
|
||
|
// algorithm, to deal with browser quirks.
|
||
|
if (hasBadBidiRects(cm.display.measure) && (order = getOrder(line, cm.doc.direction)))
|
||
|
builder.addToken = buildTokenBadBidi(builder.addToken, order)
|
||
|
builder.map = []
|
||
|
let allowFrontierUpdate = lineView != cm.display.externalMeasured && lineNo(line)
|
||
|
insertLineContent(line, builder, getLineStyles(cm, line, allowFrontierUpdate))
|
||
|
if (line.styleClasses) {
|
||
|
if (line.styleClasses.bgClass)
|
||
|
builder.bgClass = joinClasses(line.styleClasses.bgClass, builder.bgClass || "")
|
||
|
if (line.styleClasses.textClass)
|
||
|
builder.textClass = joinClasses(line.styleClasses.textClass, builder.textClass || "")
|
||
|
}
|
||
|
|
||
|
// Ensure at least a single node is present, for measuring.
|
||
|
if (builder.map.length == 0)
|
||
|
builder.map.push(0, 0, builder.content.appendChild(zeroWidthElement(cm.display.measure)))
|
||
|
|
||
|
// Store the map and a cache object for the current logical line
|
||
|
if (i == 0) {
|
||
|
lineView.measure.map = builder.map
|
||
|
lineView.measure.cache = {}
|
||
|
} else {
|
||
|
;(lineView.measure.maps || (lineView.measure.maps = [])).push(builder.map)
|
||
|
;(lineView.measure.caches || (lineView.measure.caches = [])).push({})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// See issue #2901
|
||
|
if (webkit) {
|
||
|
let last = builder.content.lastChild
|
||
|
if (/\bcm-tab\b/.test(last.className) || (last.querySelector && last.querySelector(".cm-tab")))
|
||
|
builder.content.className = "cm-tab-wrap-hack"
|
||
|
}
|
||
|
|
||
|
signal(cm, "renderLine", cm, lineView.line, builder.pre)
|
||
|
if (builder.pre.className)
|
||
|
builder.textClass = joinClasses(builder.pre.className, builder.textClass || "")
|
||
|
|
||
|
return builder
|
||
|
}
|
||
|
|
||
|
export function defaultSpecialCharPlaceholder(ch) {
|
||
|
let token = elt("span", "\u2022", "cm-invalidchar")
|
||
|
token.title = "\\u" + ch.charCodeAt(0).toString(16)
|
||
|
token.setAttribute("aria-label", token.title)
|
||
|
return token
|
||
|
}
|
||
|
|
||
|
// Build up the DOM representation for a single token, and add it to
|
||
|
// the line map. Takes care to render special characters separately.
|
||
|
function buildToken(builder, text, style, startStyle, endStyle, css, attributes) {
|
||
|
if (!text) return
|
||
|
let displayText = builder.splitSpaces ? splitSpaces(text, builder.trailingSpace) : text
|
||
|
let special = builder.cm.state.specialChars, mustWrap = false
|
||
|
let content
|
||
|
if (!special.test(text)) {
|
||
|
builder.col += text.length
|
||
|
content = document.createTextNode(displayText)
|
||
|
builder.map.push(builder.pos, builder.pos + text.length, content)
|
||
|
if (ie && ie_version < 9) mustWrap = true
|
||
|
builder.pos += text.length
|
||
|
} else {
|
||
|
content = document.createDocumentFragment()
|
||
|
let pos = 0
|
||
|
while (true) {
|
||
|
special.lastIndex = pos
|
||
|
let m = special.exec(text)
|
||
|
let skipped = m ? m.index - pos : text.length - pos
|
||
|
if (skipped) {
|
||
|
let txt = document.createTextNode(displayText.slice(pos, pos + skipped))
|
||
|
if (ie && ie_version < 9) content.appendChild(elt("span", [txt]))
|
||
|
else content.appendChild(txt)
|
||
|
builder.map.push(builder.pos, builder.pos + skipped, txt)
|
||
|
builder.col += skipped
|
||
|
builder.pos += skipped
|
||
|
}
|
||
|
if (!m) break
|
||
|
pos += skipped + 1
|
||
|
let txt
|
||
|
if (m[0] == "\t") {
|
||
|
let tabSize = builder.cm.options.tabSize, tabWidth = tabSize - builder.col % tabSize
|
||
|
txt = content.appendChild(elt("span", spaceStr(tabWidth), "cm-tab"))
|
||
|
txt.setAttribute("role", "presentation")
|
||
|
txt.setAttribute("cm-text", "\t")
|
||
|
builder.col += tabWidth
|
||
|
} else if (m[0] == "\r" || m[0] == "\n") {
|
||
|
txt = content.appendChild(elt("span", m[0] == "\r" ? "\u240d" : "\u2424", "cm-invalidchar"))
|
||
|
txt.setAttribute("cm-text", m[0])
|
||
|
builder.col += 1
|
||
|
} else {
|
||
|
txt = builder.cm.options.specialCharPlaceholder(m[0])
|
||
|
txt.setAttribute("cm-text", m[0])
|
||
|
if (ie && ie_version < 9) content.appendChild(elt("span", [txt]))
|
||
|
else content.appendChild(txt)
|
||
|
builder.col += 1
|
||
|
}
|
||
|
builder.map.push(builder.pos, builder.pos + 1, txt)
|
||
|
builder.pos++
|
||
|
}
|
||
|
}
|
||
|
builder.trailingSpace = displayText.charCodeAt(text.length - 1) == 32
|
||
|
if (style || startStyle || endStyle || mustWrap || css) {
|
||
|
let fullStyle = style || ""
|
||
|
if (startStyle) fullStyle += startStyle
|
||
|
if (endStyle) fullStyle += endStyle
|
||
|
let token = elt("span", [content], fullStyle, css)
|
||
|
if (attributes) {
|
||
|
for (let attr in attributes) if (attributes.hasOwnProperty(attr) && attr != "style" && attr != "class")
|
||
|
token.setAttribute(attr, attributes[attr])
|
||
|
}
|
||
|
return builder.content.appendChild(token)
|
||
|
}
|
||
|
builder.content.appendChild(content)
|
||
|
}
|
||
|
|
||
|
// Change some spaces to NBSP to prevent the browser from collapsing
|
||
|
// trailing spaces at the end of a line when rendering text (issue #1362).
|
||
|
function splitSpaces(text, trailingBefore) {
|
||
|
if (text.length > 1 && !/ /.test(text)) return text
|
||
|
let spaceBefore = trailingBefore, result = ""
|
||
|
for (let i = 0; i < text.length; i++) {
|
||
|
let ch = text.charAt(i)
|
||
|
if (ch == " " && spaceBefore && (i == text.length - 1 || text.charCodeAt(i + 1) == 32))
|
||
|
ch = "\u00a0"
|
||
|
result += ch
|
||
|
spaceBefore = ch == " "
|
||
|
}
|
||
|
return result
|
||
|
}
|
||
|
|
||
|
// Work around nonsense dimensions being reported for stretches of
|
||
|
// right-to-left text.
|
||
|
function buildTokenBadBidi(inner, order) {
|
||
|
return (builder, text, style, startStyle, endStyle, css, attributes) => {
|
||
|
style = style ? style + " cm-force-border" : "cm-force-border"
|
||
|
let start = builder.pos, end = start + text.length
|
||
|
for (;;) {
|
||
|
// Find the part that overlaps with the start of this text
|
||
|
let part
|
||
|
for (let i = 0; i < order.length; i++) {
|
||
|
part = order[i]
|
||
|
if (part.to > start && part.from <= start) break
|
||
|
}
|
||
|
if (part.to >= end) return inner(builder, text, style, startStyle, endStyle, css, attributes)
|
||
|
inner(builder, text.slice(0, part.to - start), style, startStyle, null, css, attributes)
|
||
|
startStyle = null
|
||
|
text = text.slice(part.to - start)
|
||
|
start = part.to
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function buildCollapsedSpan(builder, size, marker, ignoreWidget) {
|
||
|
let widget = !ignoreWidget && marker.widgetNode
|
||
|
if (widget) builder.map.push(builder.pos, builder.pos + size, widget)
|
||
|
if (!ignoreWidget && builder.cm.display.input.needsContentAttribute) {
|
||
|
if (!widget)
|
||
|
widget = builder.content.appendChild(document.createElement("span"))
|
||
|
widget.setAttribute("cm-marker", marker.id)
|
||
|
}
|
||
|
if (widget) {
|
||
|
builder.cm.display.input.setUneditable(widget)
|
||
|
builder.content.appendChild(widget)
|
||
|
}
|
||
|
builder.pos += size
|
||
|
builder.trailingSpace = false
|
||
|
}
|
||
|
|
||
|
// Outputs a number of spans to make up a line, taking highlighting
|
||
|
// and marked text into account.
|
||
|
function insertLineContent(line, builder, styles) {
|
||
|
let spans = line.markedSpans, allText = line.text, at = 0
|
||
|
if (!spans) {
|
||
|
for (let i = 1; i < styles.length; i+=2)
|
||
|
builder.addToken(builder, allText.slice(at, at = styles[i]), interpretTokenStyle(styles[i+1], builder.cm.options))
|
||
|
return
|
||
|
}
|
||
|
|
||
|
let len = allText.length, pos = 0, i = 1, text = "", style, css
|
||
|
let nextChange = 0, spanStyle, spanEndStyle, spanStartStyle, collapsed, attributes
|
||
|
for (;;) {
|
||
|
if (nextChange == pos) { // Update current marker set
|
||
|
spanStyle = spanEndStyle = spanStartStyle = css = ""
|
||
|
attributes = null
|
||
|
collapsed = null; nextChange = Infinity
|
||
|
let foundBookmarks = [], endStyles
|
||
|
for (let j = 0; j < spans.length; ++j) {
|
||
|
let sp = spans[j], m = sp.marker
|
||
|
if (m.type == "bookmark" && sp.from == pos && m.widgetNode) {
|
||
|
foundBookmarks.push(m)
|
||
|
} else if (sp.from <= pos && (sp.to == null || sp.to > pos || m.collapsed && sp.to == pos && sp.from == pos)) {
|
||
|
if (sp.to != null && sp.to != pos && nextChange > sp.to) {
|
||
|
nextChange = sp.to
|
||
|
spanEndStyle = ""
|
||
|
}
|
||
|
if (m.className) spanStyle += " " + m.className
|
||
|
if (m.css) css = (css ? css + ";" : "") + m.css
|
||
|
if (m.startStyle && sp.from == pos) spanStartStyle += " " + m.startStyle
|
||
|
if (m.endStyle && sp.to == nextChange) (endStyles || (endStyles = [])).push(m.endStyle, sp.to)
|
||
|
// support for the old title property
|
||
|
// https://github.com/codemirror/CodeMirror/pull/5673
|
||
|
if (m.title) (attributes || (attributes = {})).title = m.title
|
||
|
if (m.attributes) {
|
||
|
for (let attr in m.attributes)
|
||
|
(attributes || (attributes = {}))[attr] = m.attributes[attr]
|
||
|
}
|
||
|
if (m.collapsed && (!collapsed || compareCollapsedMarkers(collapsed.marker, m) < 0))
|
||
|
collapsed = sp
|
||
|
} else if (sp.from > pos && nextChange > sp.from) {
|
||
|
nextChange = sp.from
|
||
|
}
|
||
|
}
|
||
|
if (endStyles) for (let j = 0; j < endStyles.length; j += 2)
|
||
|
if (endStyles[j + 1] == nextChange) spanEndStyle += " " + endStyles[j]
|
||
|
|
||
|
if (!collapsed || collapsed.from == pos) for (let j = 0; j < foundBookmarks.length; ++j)
|
||
|
buildCollapsedSpan(builder, 0, foundBookmarks[j])
|
||
|
if (collapsed && (collapsed.from || 0) == pos) {
|
||
|
buildCollapsedSpan(builder, (collapsed.to == null ? len + 1 : collapsed.to) - pos,
|
||
|
collapsed.marker, collapsed.from == null)
|
||
|
if (collapsed.to == null) return
|
||
|
if (collapsed.to == pos) collapsed = false
|
||
|
}
|
||
|
}
|
||
|
if (pos >= len) break
|
||
|
|
||
|
let upto = Math.min(len, nextChange)
|
||
|
while (true) {
|
||
|
if (text) {
|
||
|
let end = pos + text.length
|
||
|
if (!collapsed) {
|
||
|
let tokenText = end > upto ? text.slice(0, upto - pos) : text
|
||
|
builder.addToken(builder, tokenText, style ? style + spanStyle : spanStyle,
|
||
|
spanStartStyle, pos + tokenText.length == nextChange ? spanEndStyle : "", css, attributes)
|
||
|
}
|
||
|
if (end >= upto) {text = text.slice(upto - pos); pos = upto; break}
|
||
|
pos = end
|
||
|
spanStartStyle = ""
|
||
|
}
|
||
|
text = allText.slice(at, at = styles[i++])
|
||
|
style = interpretTokenStyle(styles[i++], builder.cm.options)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
// These objects are used to represent the visible (currently drawn)
|
||
|
// part of the document. A LineView may correspond to multiple
|
||
|
// logical lines, if those are connected by collapsed ranges.
|
||
|
export function LineView(doc, line, lineN) {
|
||
|
// The starting line
|
||
|
this.line = line
|
||
|
// Continuing lines, if any
|
||
|
this.rest = visualLineContinued(line)
|
||
|
// Number of logical lines in this visual line
|
||
|
this.size = this.rest ? lineNo(lst(this.rest)) - lineN + 1 : 1
|
||
|
this.node = this.text = null
|
||
|
this.hidden = lineIsHidden(doc, line)
|
||
|
}
|
||
|
|
||
|
// Create a range of LineView objects for the given lines.
|
||
|
export function buildViewArray(cm, from, to) {
|
||
|
let array = [], nextPos
|
||
|
for (let pos = from; pos < to; pos = nextPos) {
|
||
|
let view = new LineView(cm.doc, getLine(cm.doc, pos), pos)
|
||
|
nextPos = pos + view.size
|
||
|
array.push(view)
|
||
|
}
|
||
|
return array
|
||
|
}
|