diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 12c29e2a3..eaa27a5c4 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2054,6 +2054,37 @@ describe('TextEditorComponent', () => { expect(decorationNode2.firstChild).toBeNull() expect(gutterB.getElement().firstChild.children.length).toBe(0) }) + + it('renders custom line number gutters', async () => { + const {component, editor} = buildComponent() + const gutterA = editor.addGutter({ + name: 'a', + priority: 1, + type: 'line-number', + class: 'a-number', + labelFn: ({bufferRow}) => `a - ${bufferRow}` + }) + const gutterB = editor.addGutter({ + name: 'b', + priority: 1, + type: 'line-number', + class: 'b-number', + labelFn: ({bufferRow}) => `b - ${bufferRow}` + }) + editor.setText('0000\n0001\n0002\n0003\n0004\n') + + await component.getNextUpdatePromise() + + const gutterAElement = gutterA.getElement() + const aNumbers = gutterAElement.querySelectorAll('div.line-number[data-buffer-row]') + const aLabels = Array.from(aNumbers, e => e.textContent) + expect(aLabels).toEqual(['a - 0', 'a - 1', 'a - 2', 'a - 3', 'a - 4', 'a - 5']) + + const gutterBElement = gutterB.getElement() + const bNumbers = gutterBElement.querySelectorAll('div.line-number[data-buffer-row]') + const bLabels = Array.from(bNumbers, e => e.textContent) + expect(bLabels).toEqual(['b - 0', 'b - 1', 'b - 2', 'b - 3', 'b - 4', 'b - 5']) + }) }) describe('block decorations', () => { diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index eba2c34f7..c49e15a25 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -6716,6 +6716,20 @@ describe('TextEditor', () => { const gutter = editor.addGutter(options) expect(editor.getGutters().length).toBe(2) expect(editor.getGutters()[1]).toBe(gutter) + expect(gutter.type).toBe('decorated') + }) + + it('can add a custom line-number gutter', () => { + expect(editor.getGutters().length).toBe(1) + const options = { + name: 'another-gutter', + priority: 2, + type: 'line-number' + } + const gutter = editor.addGutter(options) + expect(editor.getGutters().length).toBe(2) + expect(editor.getGutters()[1]).toBe(gutter) + expect(gutter.type).toBe('line-number') }) it("does not allow a custom gutter with the 'line-number' name.", () => expect(editor.addGutter.bind(editor, {name: 'line-number'})).toThrow()) diff --git a/src/gutter-container.js b/src/gutter-container.js index 3faece073..cd0c796b2 100644 --- a/src/gutter-container.js +++ b/src/gutter-container.js @@ -97,7 +97,7 @@ module.exports = class GutterContainer { // The public interface is Gutter::decorateMarker or TextEditor::decorateMarker. addGutterDecoration (gutter, marker, options) { - if (gutter.name === 'line-number') { + if (gutter.type === 'line-number') { options.type = 'line-number' } else { options.type = 'gutter' diff --git a/src/gutter.js b/src/gutter.js index 3bf7a72ea..bd5955b78 100644 --- a/src/gutter.js +++ b/src/gutter.js @@ -11,6 +11,12 @@ module.exports = class Gutter { this.name = options && options.name this.priority = (options && options.priority != null) ? options.priority : DefaultPriority this.visible = (options && options.visible != null) ? options.visible : true + this.type = (options && options.type != null) ? options.type : 'decorated' + this.labelFn = options && options.labelFn + this.className = options && options.class + + this.onMouseDown = options && options.onMouseDown + this.onMouseMove = options && options.onMouseMove this.emitter = new Emitter() } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9b30588e0..452a220ca 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -148,12 +148,13 @@ class TextEditorComponent { this.lineNumbersToRender = { maxDigits: 2, bufferRows: [], + screenRows: [], keys: [], softWrappedFlags: [], foldableFlags: [] } this.decorationsToRender = { - lineNumbers: null, + lineNumbers: new Map(), lines: null, highlights: [], cursors: [], @@ -886,7 +887,7 @@ class TextEditorComponent { queryLineNumbersToRender () { const {model} = this.props - if (!model.isLineNumberGutterVisible()) return + if (!model.anyLineNumberGutterVisible()) return if (this.showLineNumbers !== model.doesShowLineNumbers()) { this.remeasureGutterDimensions = true this.showLineNumbers = model.doesShowLineNumbers() @@ -942,7 +943,7 @@ class TextEditorComponent { queryMaxLineNumberDigits () { const {model} = this.props - if (model.isLineNumberGutterVisible()) { + if (model.anyLineNumberGutterVisible()) { const maxDigits = Math.max(2, model.getLineCount().toString().length) if (maxDigits !== this.lineNumbersToRender.maxDigits) { this.remeasureGutterDimensions = true @@ -977,7 +978,7 @@ class TextEditorComponent { } queryDecorationsToRender () { - this.decorationsToRender.lineNumbers = [] + this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines = [] this.decorationsToRender.overlays.length = 0 this.decorationsToRender.customGutter.clear() @@ -1040,7 +1041,17 @@ class TextEditorComponent { } addLineDecorationToRender (type, decoration, screenRange, reversed) { - const decorationsToRender = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers + let decorationsToRender + if (type === 'line') { + decorationsToRender = this.decorationsToRender.lines + } else { + const gutterName = decoration.gutterName || 'line-number' + decorationsToRender = this.decorationsToRender.lineNumbers.get(gutterName) + if (!decorationsToRender) { + decorationsToRender = [] + this.decorationsToRender.lineNumbers.set(gutterName, decorationsToRender) + } + } let omitLastRow = false if (screenRange.isEmpty()) { @@ -3099,7 +3110,7 @@ class GutterContainerComponent { }, $.div({style: innerStyle}, guttersToRender.map((gutter) => { - if (gutter.name === 'line-number') { + if (gutter.type === 'line-number') { return this.renderLineNumberGutter(gutter) } else { return $(CustomGutterComponent, { @@ -3118,18 +3129,29 @@ class GutterContainerComponent { renderLineNumberGutter (gutter) { const { - rootComponent, isLineNumberGutterVisible, showLineNumbers, hasInitialMeasurements, lineNumbersToRender, + rootComponent, showLineNumbers, hasInitialMeasurements, lineNumbersToRender, renderedStartRow, renderedEndRow, rowsPerTile, decorationsToRender, didMeasureVisibleBlockDecoration, scrollHeight, lineNumberGutterWidth, lineHeight } = this.props - if (!isLineNumberGutterVisible) return null + if (!gutter.isVisible()) { + return null + } + + const oneTrueLineNumberGutter = gutter.name === 'line-number' + const ref = oneTrueLineNumberGutter ? 'lineNumberGutter' : undefined + const width = oneTrueLineNumberGutter ? lineNumberGutterWidth : undefined if (hasInitialMeasurements) { const {maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags} = lineNumbersToRender return $(LineNumberGutterComponent, { - ref: 'lineNumberGutter', + ref, element: gutter.getElement(), + name: gutter.name, + className: gutter.className, + labelFn: gutter.labelFn, + onMouseDown: gutter.onMouseDown, + onMouseMove: gutter.onMouseMove, rootComponent: rootComponent, startRow: renderedStartRow, endRow: renderedEndRow, @@ -3140,18 +3162,22 @@ class GutterContainerComponent { screenRows: screenRows, softWrappedFlags: softWrappedFlags, foldableFlags: foldableFlags, - decorations: decorationsToRender.lineNumbers, + decorations: decorationsToRender.lineNumbers.get(gutter.name) || [], blockDecorations: decorationsToRender.blocks, didMeasureVisibleBlockDecoration: didMeasureVisibleBlockDecoration, height: scrollHeight, - width: lineNumberGutterWidth, + width, lineHeight: lineHeight, showLineNumbers }) } else { return $(LineNumberGutterComponent, { - ref: 'lineNumberGutter', + ref, element: gutter.getElement(), + name: gutter.name, + className: gutter.className, + onMouseDown: gutter.onMouseDown, + onMouseMove: gutter.onMouseMove, maxDigits: lineNumbersToRender.maxDigits, showLineNumbers }) @@ -3179,7 +3205,8 @@ class LineNumberGutterComponent { render () { const { rootComponent, showLineNumbers, height, width, startRow, endRow, rowsPerTile, - maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags, decorations + maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags, decorations, + className } = this.props let children = null @@ -3207,8 +3234,12 @@ class LineNumberGutterComponent { let number = null if (showLineNumbers) { - number = softWrapped ? '•' : bufferRow + 1 - number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number + if (this.props.labelFn == null) { + number = softWrapped ? '•' : bufferRow + 1 + number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number + } else { + number = this.props.labelFn({bufferRow, screenRow, foldable, softWrapped, maxDigits}) + } } // We need to adjust the line number position to account for block @@ -3235,6 +3266,7 @@ class LineNumberGutterComponent { const tileTop = rootComponent.pixelPositionBeforeBlocksForRow(tileStartRow) const tileBottom = rootComponent.pixelPositionBeforeBlocksForRow(tileEndRow) const tileHeight = tileBottom - tileTop + const tileWidth = width != null && width > 0 ? width + 'px' : '' children[i] = $.div({ key: rootComponent.idsByTileStartRow.get(tileStartRow), @@ -3243,20 +3275,26 @@ class LineNumberGutterComponent { position: 'absolute', top: 0, height: tileHeight + 'px', - width: width + 'px', + width: tileWidth, transform: `translateY(${tileTop}px)` } }, ...tileChildren) } } + let rootClassName = 'gutter line-numbers' + if (className) { + rootClassName += ' ' + className + } + return $.div( { - className: 'gutter line-numbers', - attributes: {'gutter-name': 'line-number'}, + className: rootClassName, + attributes: {'gutter-name': this.props.name}, style: {position: 'relative', height: ceilToPhysicalPixelBoundary(height) + 'px'}, on: { - mousedown: this.didMouseDown + mousedown: this.didMouseDown, + mousemove: this.didMouseMove } }, $.div({key: 'placeholder', className: 'line-number dummy', style: {visibility: 'hidden'}}, @@ -3278,6 +3316,8 @@ class LineNumberGutterComponent { if (oldProps.endRow !== newProps.endRow) return true if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true if (oldProps.maxDigits !== newProps.maxDigits) return true + if (oldProps.labelFn !== newProps.labelFn) return true + if (oldProps.className !== newProps.className) return true if (newProps.didMeasureVisibleBlockDecoration) return true if (!arraysEqual(oldProps.keys, newProps.keys)) return true if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true @@ -3324,7 +3364,27 @@ class LineNumberGutterComponent { } didMouseDown (event) { - this.props.rootComponent.didMouseDownOnLineNumberGutter(event) + if (this.props.onMouseDown == null) { + this.props.rootComponent.didMouseDownOnLineNumberGutter(event) + } else { + const {bufferRow, screenRow} = event.target.dataset + this.props.onMouseDown({ + bufferRow: parseInt(bufferRow, 10), + screenRow: parseInt(screenRow, 10), + domEvent: event + }) + } + } + + didMouseMove (event) { + if (this.props.onMouseMove != null) { + const {bufferRow, screenRow} = event.target.dataset + this.props.onMouseMove({ + bufferRow: parseInt(bufferRow, 10), + screenRow: parseInt(screenRow, 10), + domEvent: event + }) + } } } @@ -3332,7 +3392,8 @@ class LineNumberComponent { constructor (props) { const {className, width, marginTop, bufferRow, screenRow, number, nodePool} = props this.props = props - const style = {width: width + 'px'} + const style = {} + if (width != null && width > 0) style.width = width + 'px' if (marginTop != null && marginTop > 0) style.marginTop = marginTop + 'px' this.element = nodePool.getElement('DIV', className, style) this.element.dataset.bufferRow = bufferRow @@ -3352,22 +3413,31 @@ class LineNumberComponent { if (this.props.bufferRow !== bufferRow) this.element.dataset.bufferRow = bufferRow if (this.props.screenRow !== screenRow) this.element.dataset.screenRow = screenRow if (this.props.className !== className) this.element.className = className - if (this.props.width !== width) this.element.style.width = width + 'px' + if (this.props.width !== width) { + if (width != null && width > 0) { + this.element.style.width = width + 'px' + } else { + this.element.style.width = '' + } + } if (this.props.marginTop !== marginTop) { - if (marginTop != null) { + if (marginTop != null && marginTop > 0) { this.element.style.marginTop = marginTop + 'px' } else { this.element.style.marginTop = '' } } + if (this.props.number !== number) { - if (number) { - this.element.insertBefore(nodePool.getTextNode(number), this.element.firstChild) - } else { + if (this.props.number != null) { const numberNode = this.element.firstChild numberNode.remove() nodePool.release(numberNode) } + + if (number != null) { + this.element.insertBefore(nodePool.getTextNode(number), this.element.firstChild) + } } this.props = props @@ -3393,9 +3463,13 @@ class CustomGutterComponent { } render () { + let className = 'gutter' + if (this.props.className) { + className += ' ' + this.props.className + } return $.div( { - className: 'gutter', + className, attributes: {'gutter-name': this.props.name}, style: { display: this.props.visible ? '' : 'none' diff --git a/src/text-editor.js b/src/text-editor.js index efa7353e0..ba063f7f0 100644 --- a/src/text-editor.js +++ b/src/text-editor.js @@ -258,6 +258,7 @@ class TextEditor { this.gutterContainer = new GutterContainer(this) this.lineNumberGutter = this.gutterContainer.addGutter({ name: 'line-number', + type: 'line-number', priority: 0, visible: params.lineNumberGutterVisible }) @@ -1020,6 +1021,10 @@ class TextEditor { isLineNumberGutterVisible () { return this.lineNumberGutter.isVisible() } + anyLineNumberGutterVisible () { + return this.getGutters().some(gutter => gutter.type === 'line-number' && gutter.visible) + } + onDidChangeLineNumberGutterVisible (callback) { return this.emitter.on('did-change-line-number-gutter-visible', callback) } @@ -4211,6 +4216,29 @@ class TextEditor { // window. (default: -100) // * `visible` (optional) {Boolean} specifying whether the gutter is visible // initially after being created. (default: true) + // * `type` (optional) {String} specifying the type of gutter to create. `'decorated'` + // gutters are useful as a destination for decorations created with {Gutter::decorateMarker}. + // `'line-number'` gutters. + // * `class` (optional) {String} added to the CSS classnames of the gutter's root DOM element. + // * `labelFn` (optional) {Function} called by a `'line-number'` gutter to generate the label for each line number + // element. Should return a {String} that will be used to label the corresponding line. + // * `lineData` an {Object} containing information about each line to label. + // * `bufferRow` {Number} indicating the zero-indexed buffer index of this line. + // * `screenRow` {Number} indicating the zero-indexed screen index. + // * `foldable` {Boolean} that is `true` if a fold may be created here. + // * `softWrapped` {Boolean} if this screen row is the soft-wrapped continuation of the same buffer row. + // * `maxDigits` {Number} the maximum number of digits necessary to represent any known screen row. + // * `onMouseDown` (optional) {Function} to be called when a mousedown event is received by a line-number + // element within this `type: 'line-number'` {Gutter}. If unspecified, the default behavior is to select the + // clicked buffer row. + // * `lineData` an {Object} containing information about the line that's being clicked. + // * `bufferRow` {Number} of the originating line element + // * `screenRow` {Number} + // * `onMouseMove` (optional) {Function} to be called when a mousemove event occurs on a line-number element within + // within this `type: 'line-number'` {Gutter}. + // * `lineData` an {Object} containing information about the line that's being clicked. + // * `bufferRow` {Number} of the originating line element + // * `screenRow` {Number} // // Returns the newly-created {Gutter}. addGutter (options) {