lib/mixins/canvas-drawer.js
'use babel'
import _ from 'underscore-plus'
import Mixin from 'mixto'
import CanvasLayer from '../canvas-layer'
/**
* The `CanvasDrawer` mixin is responsible for the rendering of a `Minimap`
* in a `canvas` element.
*
* This mixin is injected in the `MinimapElement` prototype, so all these
* methods are available on any `MinimapElement` instance.
*/
export default class CanvasDrawer extends Mixin {
/**
* Initializes the canvas elements needed to perform the `Minimap` rendering.
*/
initializeCanvas () {
/**
* The main canvas layer where lines are rendered.
* @type {CanvasLayer}
*/
this.tokensLayer = new CanvasLayer()
/**
* The canvas layer for decorations below the text.
* @type {CanvasLayer}
*/
this.backLayer = new CanvasLayer()
/**
* The canvas layer for decorations above the text.
* @type {CanvasLayer}
*/
this.frontLayer = new CanvasLayer()
if (!this.pendingChanges) {
/**
* Stores the changes from the text editor.
* @type {Array<Object>}
* @access private
*/
this.pendingChanges = []
}
if (!this.pendingDecorationChanges) {
/**
* Stores the changes from the minimap decorations.
* @type {Array<Object>}
* @access private
*/
this.pendingDecorationChanges = []
}
}
/**
* Returns the uppermost canvas in the MinimapElement.
*
* @return {HTMLCanvasElement} the html canvas element
*/
getFrontCanvas () { return this.frontLayer.canvas }
/**
* Attaches the canvases into the specified container.
*
* @param {HTMLElement} parent the canvases' container
* @access private
*/
attachCanvases (parent) {
this.backLayer.attach(parent)
this.tokensLayer.attach(parent)
this.frontLayer.attach(parent)
}
/**
* Changes the size of all the canvas layers at once.
*
* @param {number} width the new width for the three canvases
* @param {number} height the new height for the three canvases
* @access private
*/
setCanvasesSize (width, height) {
this.backLayer.setSize(width, height)
this.tokensLayer.setSize(width, height)
this.frontLayer.setSize(width, height)
}
/**
* Performs an update of the rendered `Minimap` based on the changes
* registered in the instance.
*/
updateCanvas () {
let firstRow = this.minimap.getFirstVisibleScreenRow()
let lastRow = this.minimap.getLastVisibleScreenRow()
this.updateTokensLayer(firstRow, lastRow)
this.updateDecorationsLayers(firstRow, lastRow)
this.pendingChanges = []
this.pendingDecorationChanges = []
/**
* The first row in the last render of the offscreen canvas.
* @type {number}
* @access private
*/
this.offscreenFirstRow = firstRow
/**
* The last row in the last render of the offscreen canvas.
* @type {number}
* @access private
*/
this.offscreenLastRow = lastRow
}
/**
* Performs an update of the tokens layer using the pending changes array.
*
* @param {number} firstRow firstRow the first row of the range to update
* @param {number} lastRow lastRow the last row of the range to update
* @access private
*/
updateTokensLayer (firstRow, lastRow) {
let intactRanges = this.computeIntactRanges(firstRow, lastRow, this.pendingChanges)
this.redrawRangesOnLayer(this.tokensLayer, intactRanges, firstRow, lastRow, this.drawLines)
}
/**
* Performs an update of the decoration layers using the pending changes
* and the pending decoration changes arrays.
*
* @param {number} firstRow firstRow the first row of the range to update
* @param {number} lastRow lastRow the last row of the range to update
* @access private
*/
updateDecorationsLayers (firstRow, lastRow) {
let intactRanges = this.computeIntactRanges(firstRow, lastRow, this.pendingChanges.concat(this.pendingDecorationChanges))
this.redrawRangesOnLayer(this.backLayer, intactRanges, firstRow, lastRow, this.drawBackDecorationsForLines)
this.redrawRangesOnLayer(this.frontLayer, intactRanges, firstRow, lastRow, this.drawFrontDecorationsForLines)
}
/**
* Routine used to render changes in specific ranges for one layer.
*
* @param {CanvasLayer} layer the layer to redraw
* @param {Array<Object>} intactRanges an array of the ranges to leave intact
* @param {number} firstRow firstRow the first row of the range to update
* @param {number} lastRow lastRow the last row of the range to update
* @param {Function} method the render method to use for the lines drawing
* @access private
*/
redrawRangesOnLayer (layer, intactRanges, firstRow, lastRow, method) {
let lineHeight = this.minimap.getLineHeight() * devicePixelRatio
layer.clearCanvas()
if (intactRanges.length === 0) {
method.call(this, firstRow, lastRow, 0)
} else {
for (let j = 0, len = intactRanges.length; j < len; j++) {
let intact = intactRanges[j]
layer.copyPartFromOffscreen(
intact.offscreenRow * lineHeight,
(intact.start - firstRow) * lineHeight,
(intact.end - intact.start) * lineHeight
)
}
this.drawLinesForRanges(method, intactRanges, firstRow, lastRow)
}
layer.resetOffscreenSize()
layer.copyToOffscreen()
}
/**
* Renders the lines between the intact ranges when an update has pending
* changes.
*
* @param {Function} method the render method to use for the lines drawing
* @param {Array<Object>} intactRanges the intact ranges in the minimap
* @param {number} firstRow the first row of the rendered region
* @param {number} lastRow the last row of the rendered region
* @access private
*/
drawLinesForRanges (method, ranges, firstRow, lastRow) {
let currentRow = firstRow
for (let i = 0, len = ranges.length; i < len; i++) {
let range = ranges[i]
method.call(this, currentRow, range.start - 1, currentRow - firstRow)
currentRow = range.end
}
if (currentRow <= lastRow) {
method.call(this, currentRow, lastRow, currentRow - firstRow)
}
}
// ###### ####### ## ####### ######## ######
// ## ## ## ## ## ## ## ## ## ## ##
// ## ## ## ## ## ## ## ## ##
// ## ## ## ## ## ## ######## ######
// ## ## ## ## ## ## ## ## ##
// ## ## ## ## ## ## ## ## ## ## ##
// ###### ####### ######## ####### ## ## ######
/**
* Returns the opacity value to use when rendering the `Minimap` text.
*
* @return {Number} the text opacity value
*/
getTextOpacity () { return this.textOpacity }
/**
* Returns the default text color for an editor content.
*
* The color value is directly read from the `TextEditorView` computed styles.
*
* @return {string} a CSS color
*/
getDefaultColor () {
let color = this.retrieveStyleFromDom(['.editor'], 'color', false, true)
return this.transparentize(color, this.getTextOpacity())
}
/**
* Returns the text color for the passed-in `token` object.
*
* The color value is read from the DOM by creating a node structure that
* match the token `scope` property.
*
* @param {Object} token a `TextEditor` token
* @return {string} the CSS color for the provided token
*/
getTokenColor (token) {
let scopes = token.scopeDescriptor || token.scopes
let color = this.retrieveStyleFromDom(scopes, 'color')
return this.transparentize(color, this.getTextOpacity())
}
/**
* Returns the background color for the passed-in `decoration` object.
*
* The color value is read from the DOM by creating a node structure that
* match the decoration `scope` property unless the decoration provides
* its own `color` property.
*
* @param {Decoration} decoration the decoration to get the color for
* @return {string} the CSS color for the provided decoration
*/
getDecorationColor (decoration) {
let properties = decoration.getProperties()
if (properties.color) { return properties.color }
let scopeString = properties.scope.split(/\s+/)
return this.retrieveStyleFromDom(scopeString, 'background-color', false)
}
/**
* Converts a `rgb(...)` color into a `rgba(...)` color with the specified
* opacity.
*
* @param {string} color the CSS RGB color to transparentize
* @param {number} [opacity=1] the opacity amount
* @return {string} the transparentized CSS color
* @access private
*/
transparentize (color, opacity = 1) {
return color.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`)
}
// ######## ######## ### ## ##
// ## ## ## ## ## ## ## ## ##
// ## ## ## ## ## ## ## ## ##
// ## ## ######## ## ## ## ## ##
// ## ## ## ## ######### ## ## ##
// ## ## ## ## ## ## ## ## ##
// ######## ## ## ## ## ### ###
/**
* Draws back decorations on the corresponding layer.
*
* The lines range to draw is specified by the `firstRow` and `lastRow`
* parameters.
*
* @param {number} firstRow the first row to render
* @param {number} lastRow the last row to render
* @param {number} offsetRow the relative offset to apply to rows when
* rendering them
* @access private
*/
drawBackDecorationsForLines (firstRow, lastRow, offsetRow) {
if (firstRow > lastRow) { return }
let lineHeight = this.minimap.getLineHeight() * devicePixelRatio
let charHeight = this.minimap.getCharHeight() * devicePixelRatio
let charWidth = this.minimap.getCharWidth() * devicePixelRatio
let decorations = this.minimap.decorationsByTypeThenRows(firstRow, lastRow)
let {width: canvasWidth, height: canvasHeight} = this.tokensLayer.getSize()
let renderData = {
context: this.backLayer.context,
canvasWidth: canvasWidth,
canvasHeight: canvasHeight,
lineHeight: lineHeight,
charWidth: charWidth,
charHeight: charHeight
}
for (let screenRow = firstRow; screenRow <= lastRow; screenRow++) {
renderData.row = offsetRow + (screenRow - firstRow)
renderData.yRow = renderData.row * lineHeight
renderData.screenRow = screenRow
this.drawDecorations(screenRow, decorations, 'line', renderData, this.drawLineDecoration)
this.drawDecorations(screenRow, decorations, 'highlight-under', renderData, this.drawHighlightDecoration)
}
this.backLayer.context.fill()
}
/**
* Draws front decorations on the corresponding layer.
*
* The lines range to draw is specified by the `firstRow` and `lastRow`
* parameters.
*
* @param {number} firstRow the first row to render
* @param {number} lastRow the last row to render
* @param {number} offsetRow the relative offset to apply to rows when
* rendering them
* @access private
*/
drawFrontDecorationsForLines (firstRow, lastRow, offsetRow) {
if (firstRow > lastRow) { return }
let lineHeight = this.minimap.getLineHeight() * devicePixelRatio
let charHeight = this.minimap.getCharHeight() * devicePixelRatio
let charWidth = this.minimap.getCharWidth() * devicePixelRatio
let decorations = this.minimap.decorationsByTypeThenRows(firstRow, lastRow)
let {width: canvasWidth, height: canvasHeight} = this.tokensLayer.getSize()
let renderData = {
context: this.frontLayer.context,
canvasWidth: canvasWidth,
canvasHeight: canvasHeight,
lineHeight: lineHeight,
charWidth: charWidth,
charHeight: charHeight
}
for (let screenRow = firstRow; screenRow <= lastRow; screenRow++) {
renderData.row = offsetRow + (screenRow - firstRow)
renderData.yRow = renderData.row * lineHeight
renderData.screenRow = screenRow
this.drawDecorations(screenRow, decorations, 'highlight-over', renderData, this.drawHighlightDecoration)
this.drawDecorations(screenRow, decorations, 'highlight-outline', renderData, this.drawHighlightOutlineDecoration)
}
renderData.context.fill()
}
/**
* Draws lines on the corresponding layer.
*
* The lines range to draw is specified by the `firstRow` and `lastRow`
* parameters.
*
* @param {number} firstRow the first row to render
* @param {number} lastRow the last row to render
* @param {number} offsetRow the relative offset to apply to rows when
* rendering them
* @access private
*/
drawLines (firstRow, lastRow, offsetRow) {
if (firstRow > lastRow) { return }
let lines = this.getTextEditor().tokenizedLinesForScreenRows(firstRow, lastRow)
let lineHeight = this.minimap.getLineHeight() * devicePixelRatio
let charHeight = this.minimap.getCharHeight() * devicePixelRatio
let charWidth = this.minimap.getCharWidth() * devicePixelRatio
let displayCodeHighlights = this.displayCodeHighlights
let context = this.tokensLayer.context
let {width: canvasWidth} = this.tokensLayer.getSize()
let line = lines[0]
let invisibleRegExp = this.getInvisibleRegExp(line)
for (let i = 0, len = lines.length; i < len; i++) {
line = lines[i]
let x = 0
let yRow = (offsetRow + i) * lineHeight
if ((line != null ? line.tokens : void 0) != null) {
let tokens = line.tokens
for (let j = 0, tokensCount = tokens.length; j < tokensCount; j++) {
let token = tokens[j]
let w = token.screenDelta
if (!token.isOnlyWhitespace()) {
let color = displayCodeHighlights ? this.getTokenColor(token) : this.getDefaultColor()
let value = token.value
if (invisibleRegExp != null) {
value = value.replace(invisibleRegExp, ' ')
}
x = this.drawToken(context, value, color, x, yRow, charWidth, charHeight)
} else {
x += w * charWidth
}
if (x > canvasWidth) { break }
}
}
}
context.fill()
}
/**
* Returns the regexp to replace invisibles substitution characters
* in editor lines.
*
* @param {TokenizedLine} line a tokenized lize to read the invisible
* characters
* @return {RegExp} the regular expression to match invisible characters
* @access private
*/
getInvisibleRegExp (line) {
if ((line != null) && (line.invisibles != null)) {
let invisibles = []
if (line.invisibles.cr != null) { invisibles.push(line.invisibles.cr) }
if (line.invisibles.eol != null) { invisibles.push(line.invisibles.eol) }
if (line.invisibles.space != null) { invisibles.push(line.invisibles.space) }
if (line.invisibles.tab != null) { invisibles.push(line.invisibles.tab) }
return RegExp(invisibles.filter((s) => {
return typeof s === 'string'
}).map(_.escapeRegExp).join('|'), 'g')
}
}
/**
* Draws a single token on the given context.
*
* @param {CanvasRenderingContext2D} context the target canvas context
* @param {string} text the token's text content
* @param {string} color the token's CSS color
* @param {number} x the x position of the token in the line
* @param {number} y the y position of the line in the minimap
* @param {number} charWidth the width of a character in the minimap
* @param {number} charHeight the height of a character in the minimap
* @return {number} the x position at the end of the token
* @access private
*/
drawToken (context, text, color, x, y, charWidth, charHeight) {
context.fillStyle = color
let chars = 0
for (let j = 0, len = text.length; j < len; j++) {
let char = text[j]
if (/\s/.test(char)) {
if (chars > 0) {
context.fillRect(x - (chars * charWidth), y, chars * charWidth, charHeight)
}
chars = 0
} else {
chars++
}
x += charWidth
}
if (chars > 0) {
context.fillRect(x - (chars * charWidth), y, chars * charWidth, charHeight)
}
return x
}
/**
* Draws the specified decorations for the current `screenRow`.
*
* The `decorations` object contains all the decorations grouped by type and
* then rows.
*
* @param {number} screenRow the screen row index for which
* render decorations
* @param {Object} decorations the object containing all the decorations
* @param {string} type the type of decorations to render
* @param {Object} renderData the object containing the render data
* @param {Fundtion} renderMethod the method to call to render
* the decorations
* @access private
*/
drawDecorations (screenRow, decorations, type, renderData, renderMethod) {
let ref
decorations = (ref = decorations[type]) != null ? ref[screenRow] : void 0
if (decorations != null ? decorations.length : void 0) {
for (let i = 0, len = decorations.length; i < len; i++) {
renderMethod.call(this, decorations[i], renderData)
}
}
}
/**
* Draws a line decoration.
*
* @param {Decoration} decoration the decoration to render
* @param {Object} data the data need to perform the render
* @access private
*/
drawLineDecoration (decoration, data) {
data.context.fillStyle = this.getDecorationColor(decoration)
data.context.fillRect(0, data.yRow, data.canvasWidth, data.lineHeight)
}
/**
* Draws a highlight decoration.
*
* It renders only the part of the highlight corresponding to the specified
* row.
*
* @param {Decoration} decoration the decoration to render
* @param {Object} data the data need to perform the render
* @access private
*/
drawHighlightDecoration (decoration, data) {
let range = decoration.getMarker().getScreenRange()
let rowSpan = range.end.row - range.start.row
data.context.fillStyle = this.getDecorationColor(decoration)
if (rowSpan === 0) {
let colSpan = range.end.column - range.start.column
data.context.fillRect(range.start.column * data.charWidth, data.yRow, colSpan * data.charWidth, data.lineHeight)
} else if (data.screenRow === range.start.row) {
let x = range.start.column * data.charWidth
data.context.fillRect(x, data.yRow, data.canvasWidth - x, data.lineHeight)
} else if (data.screenRow === range.end.row) {
data.context.fillRect(0, data.yRow, range.end.column * data.charWidth, data.lineHeight)
} else {
data.context.fillRect(0, data.yRow, data.canvasWidth, data.lineHeight)
}
}
/**
* Draws a highlight outline decoration.
*
* It renders only the part of the highlight corresponding to the specified
* row.
*
* @param {Decoration} decoration the decoration to render
* @param {Object} data the data need to perform the render
* @access private
*/
drawHighlightOutlineDecoration (decoration, data) {
let bottomWidth, colSpan, width, xBottomStart, xEnd, xStart
let {lineHeight, charWidth, canvasWidth, screenRow} = data
let range = decoration.getMarker().getScreenRange()
let rowSpan = range.end.row - range.start.row
let yStart = data.yRow
let yEnd = yStart + lineHeight
data.context.fillStyle = this.getDecorationColor(decoration)
if (rowSpan === 0) {
colSpan = range.end.column - range.start.column
width = colSpan * charWidth
xStart = range.start.column * charWidth
xEnd = xStart + width
data.context.fillRect(xStart, yStart, width, 1)
data.context.fillRect(xStart, yEnd, width, 1)
data.context.fillRect(xStart, yStart, 1, lineHeight)
data.context.fillRect(xEnd, yStart, 1, lineHeight)
} else if (rowSpan === 1) {
xStart = range.start.column * data.charWidth
xEnd = range.end.column * data.charWidth
if (screenRow === range.start.row) {
width = data.canvasWidth - xStart
xBottomStart = Math.max(xStart, xEnd)
bottomWidth = data.canvasWidth - xBottomStart
data.context.fillRect(xStart, yStart, width, 1)
data.context.fillRect(xBottomStart, yEnd, bottomWidth, 1)
data.context.fillRect(xStart, yStart, 1, lineHeight)
data.context.fillRect(canvasWidth - 1, yStart, 1, lineHeight)
} else {
width = canvasWidth - xStart
bottomWidth = canvasWidth - xEnd
data.context.fillRect(0, yStart, xStart, 1)
data.context.fillRect(0, yEnd, xEnd, 1)
data.context.fillRect(0, yStart, 1, lineHeight)
data.context.fillRect(xEnd, yStart, 1, lineHeight)
}
} else {
xStart = range.start.column * charWidth
xEnd = range.end.column * charWidth
if (screenRow === range.start.row) {
width = canvasWidth - xStart
data.context.fillRect(xStart, yStart, width, 1)
data.context.fillRect(xStart, yStart, 1, lineHeight)
data.context.fillRect(canvasWidth - 1, yStart, 1, lineHeight)
} else if (screenRow === range.end.row) {
width = canvasWidth - xStart
data.context.fillRect(0, yEnd, xEnd, 1)
data.context.fillRect(0, yStart, 1, lineHeight)
data.context.fillRect(xEnd, yStart, 1, lineHeight)
} else {
data.context.fillRect(0, yStart, 1, lineHeight)
data.context.fillRect(canvasWidth - 1, yStart, 1, lineHeight)
if (screenRow === range.start.row + 1) {
data.context.fillRect(0, yStart, xStart, 1)
}
if (screenRow === range.end.row - 1) {
data.context.fillRect(xEnd, yEnd, canvasWidth - xEnd, 1)
}
}
}
}
// ######## ### ## ## ###### ######## ######
// ## ## ## ## ### ## ## ## ## ## ##
// ## ## ## ## #### ## ## ## ##
// ######## ## ## ## ## ## ## #### ###### ######
// ## ## ######### ## #### ## ## ## ##
// ## ## ## ## ## ### ## ## ## ## ##
// ## ## ## ## ## ## ###### ######## ######
/**
* Computes the ranges that are not affected by the current pending changes.
*
* @param {number} firstRow the first row of the rendered region
* @param {number} lastRow the last row of the rendered region
* @return {Array<Object>} the intact ranges in the rendered region
* @access private
*/
computeIntactRanges (firstRow, lastRow, changes) {
if ((this.offscreenFirstRow == null) && (this.offscreenLastRow == null)) {
return []
}
// At first, the whole range is considered intact
let intactRanges = [
{
start: this.offscreenFirstRow,
end: this.offscreenLastRow,
offscreenRow: 0
}
]
for (let i = 0, len = changes.length; i < len; i++) {
let change = changes[i]
let newIntactRanges = []
for (let j = 0, intactLen = intactRanges.length; j < intactLen; j++) {
let range = intactRanges[j]
if (change.end < range.start && change.screenDelta !== 0) {
// The change is above of the range and lines are either
// added or removed
newIntactRanges.push({
start: range.start + change.screenDelta,
end: range.end + change.screenDelta,
offscreenRow: range.offscreenRow
})
} else if (change.end < range.start || change.start > range.end) {
// The change is outside the range but didn't added
// or removed lines
newIntactRanges.push(range)
} else {
// The change is within the range, there's one intact range
// from the range start to the change start
if (change.start > range.start) {
newIntactRanges.push({
start: range.start,
end: change.start - 1,
offscreenRow: range.offscreenRow
})
}
if (change.end < range.end) {
// The change ends within the range
if (change.bufferDelta !== 0) {
// Lines are added or removed, the intact range starts in the
// next line after the change end plus the screen delta
newIntactRanges.push({
start: change.end + change.screenDelta + 1,
end: range.end + change.screenDelta,
offscreenRow: range.offscreenRow + change.end + 1 - range.start
})
} else {
// No lines are added, the intact range starts on the line after
// the change end
newIntactRanges.push({
start: change.end + 1,
end: range.end,
offscreenRow: range.offscreenRow + change.end + 1 - range.start
})
}
}
}
}
intactRanges = newIntactRanges
}
return this.truncateIntactRanges(intactRanges, firstRow, lastRow)
}
/**
* Truncates the intact ranges so that they doesn't expand past the visible
* area of the minimap.
*
* @param {Array<Object>} intactRanges the initial array of ranges
* @param {number} firstRow the first row of the rendered region
* @param {number} lastRow the last row of the rendered region
* @return {Array<Object>} the array of truncated ranges
* @access private
*/
truncateIntactRanges (intactRanges, firstRow, lastRow) {
let i = 0
while (i < intactRanges.length) {
let range = intactRanges[i]
if (range.start < firstRow) {
range.offscreenRow += firstRow - range.start
range.start = firstRow
}
if (range.end > lastRow) { range.end = lastRow }
if (range.start >= range.end) { intactRanges.splice(i--, 1) }
i++
}
return intactRanges.sort((a, b) => {
return a.offscreenRow - b.offscreenRow
})
}
}