Skip to content

Commit

Permalink
feat: marker groups (#5113)
Browse files Browse the repository at this point in the history
* feat: markers with tooltips

* refactor: rename TooltipMarkerGroup to MarkerGroup

* unify diagnostic and occurrence markers

* cleanup

---------

Co-authored-by: Zakhar Kozlov <[email protected]>
Co-authored-by: nightwing <[email protected]>
  • Loading branch information
3 people authored Apr 19, 2023
1 parent 23d4df6 commit 01d4605
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 23 deletions.
13 changes: 13 additions & 0 deletions ace.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export namespace Ace {
value: string;
session: EditSession;
relativeLineNumbers: boolean;
enableMultiselect: boolean;
enableKeyboardAccessibility: boolean;
}

Expand Down Expand Up @@ -275,6 +276,18 @@ export namespace Ace {
type: string;
}

export interface MarkerGroupItem {
range: Range;
className: string;
}

export class MarkerGroup {
constructor(session: EditSession);
setMarkers: (markers: MarkerGroupItem[]) => void;
getMarkerAtPosition: (pos: Position) => MarkerGroupItem;
}


export interface Command {
name?: string;
bindKey?: string | { mac?: string, win?: string };
Expand Down
110 changes: 95 additions & 15 deletions demo/kitchen-sink/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var HashHandler = require("ace/keyboard/hash_handler").HashHandler;

var Renderer = require("ace/virtual_renderer").VirtualRenderer;
var Editor = require("ace/editor").Editor;
var Range = require("ace/range").Range;

var whitespace = require("ace/ext/whitespace");

Expand All @@ -44,7 +45,6 @@ var ElasticTabstopsLite = require("ace/ext/elastic_tabstops_lite").ElasticTabsto

var IncrementalSearch = require("ace/incremental_search").IncrementalSearch;


var TokenTooltip = require("./token_tooltip").TokenTooltip;
require("ace/config").defineOptions(Editor.prototype, "editor", {
showTokenInfo: {
Expand All @@ -61,7 +61,7 @@ require("ace/config").defineOptions(Editor.prototype, "editor", {
return !!this.tokenTooltip;
},
handlesSet: true
},
}
});

require("ace/config").defineOptions(Editor.prototype, "editor", {
Expand All @@ -80,30 +80,110 @@ require("ace/config").defineOptions(Editor.prototype, "editor", {
});

var {HoverTooltip} = require("ace/tooltip");
var MarkerGroup = require("ace/marker_group").MarkerGroup;
var docTooltip = new HoverTooltip();
function loadLanguageProvider(editor) {
require([
"https://www.unpkg.com/ace-linters/build/ace-linters.js"
], (m) => {
window.languageProvider = m.LanguageProvider.fromCdn("https://www.unpkg.com/ace-linters/build");
window.languageProvider.registerEditor(editor);
], function(m) {
var languageProvider = m.LanguageProvider.fromCdn("https://www.unpkg.com/ace-linters/build", {
functionality: {
hover: true,
completion: {
overwriteCompleters: true
},
completionResolve: true,
format: true,
documentHighlights: true,
signatureHelp: false
}
});
window.languageProvider = languageProvider;
languageProvider.registerEditor(editor);
// hack to replace tooltip implementation from ace-linters with hover tooltip
// can be removed when ace-linters is updated to use MarkerGroup and HoverTooltip
if (languageProvider.$descriptionTooltip)
editor.off("mousemove", languageProvider.$descriptionTooltip.onMouseMove);

languageProvider.$messageController.$worker.addEventListener("message", function(e) {
var id = e.data.sessionId.split(".")[0];
var session = languageProvider.$getSessionLanguageProvider({id: id})?.session;
if (e.data.type == 6) {
// annotation message
e.stopPropagation();
if (session) {
showAnnotations(session, e.data.value);
}
} else if (e.data.type == 13) {
// highlights message
if (session) showOccurrenceMarkers(session, e.data.value);
}
}, true);
function showOccurrenceMarkers(session, positions) {
if (!session.state.occurrenceMarkers) {
session.state.occurrenceMarkers = new MarkerGroup(session);
}
session.state.occurrenceMarkers.setMarkers(positions.map(function(el) {
var r = el.range;
return {
range: new Range(r.start.line, r.start.character, r.end.line, r.end.character),
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#documentHighlightKind
className: el.kind == 2
? "language_highlight_read"
: el.kind == 3
? "language_highlight_write"
: "language_highlight_text"
};
}));
}
function showAnnotations(session, diagnostics) {
session.clearAnnotations();
let annotations = diagnostics.map((el) => {
console.log(el.severity, el)
return {
row: el.range.start.line,
column: el.range.start.character,
text: el.message,
type: el.severity === 1 ? "error" : el.severity === 2 ? "warning" : "info"
};
});
if (annotations && annotations.length > 0) {
session.setAnnotations(annotations);
}

if (!session.state) session.state = {}
if (!session.state.diagnosticMarkers) {
session.state.diagnosticMarkers = new MarkerGroup(session);
}
session.state.diagnosticMarkers.setMarkers(diagnostics.map(function(el) {
var r = el.range;
return {
range: new Range(r.start.line, r.start.character, r.end.line, r.end.character),
tooltipText: el.message,
className: "language_highlight_error"
};
}));
};

docTooltip.setDataProvider(function(e, editor) {
var renderer = editor.renderer;

let session = editor.session;
let docPos = e.getDocumentPosition() ;
let docPos = e.getDocumentPosition();

languageProvider.doHover(session, docPos, function(hover) {
if (!hover) {
return;
}
// todo should ace itself handle markdown?
var domNode = dom.buildDom(["p", {}, hover.content.text]);
docTooltip.showForRange(editor, hover.range, domNode, e);
var errorMarker = session.state?.diagnosticMarkers.getMarkerAtPosition(docPos);
var range = hover?.range || errorMarker?.range;
if (!range) return;
var hoverNode = hover && dom.buildDom(["div", {}])
if (hoverNode) {
hover.content.text = hover.content.text.replace(/(?!^)`{3}/gm, "\n$&");
// todo render markdown using ace markdown mode
hoverNode.innerHTML = languageProvider.getTooltipText(hover);
};

var domNode = dom.buildDom(["div", {},
errorMarker && ["div", {}, errorMarker.tooltipText.trim()],
hoverNode
]);
docTooltip.showForRange(editor, range, domNode, e);
});
});

Expand Down
32 changes: 32 additions & 0 deletions demo/kitchen-sink/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,36 @@ body {
z-index: 1000!important;
opacity: 1!important;
font-size: 1em!important;
}


.language_highlight_error {
position: absolute;
border-bottom: dotted 1px #e00404;
z-index: 2000;
border-radius: 0;
}
.language_highlight_warning {
position: absolute;
border-bottom: solid 1px #DDC50F;
z-index: 2000;
border-radius: 0;
}
.language_highlight_info {
position: absolute;
border-bottom: dotted 1px #999;
z-index: 2000;
border-radius: 0;
}
.language_highlight_text, .language_highlight_read, .language_highlight_write {
position: absolute;
box-sizing: border-box;
border: solid 1px #888;
z-index: 2000;
}
.language_highlight_write {
border: solid 1px #F88;
}
.ace_tooltip pre {
margin: 0;
}
102 changes: 102 additions & 0 deletions src/marker_group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use strict";

/*
Potential improvements:
- use binary search when looking for hover match
*/

class MarkerGroup {
constructor(session) {
this.markers = [];
this.session = session;
session.addDynamicMarker(this);
}

/**
* Finds the first marker containing pos
* @param {Position} pos
* @returns Ace.MarkerGroupItem
*/
getMarkerAtPosition(pos) {
return this.markers.find(function(marker) {
return marker.range.contains(pos.row, pos.column);
});
}

/**
* Comparator for Array.sort function, which sorts marker definitions by their positions
*
* @param {Ace.MarkerGroupItem} a first marker.
* @param {Ace.MarkerGroupItem} b second marker.
* @returns {number} negative number if a should be before b, positive number if b should be before a, 0 otherwise.
*/
markersComparator(a, b) {
return a.range.start.row - b.range.start.row;
}

/**
* Sets marker definitions to be rendered. Limits the number of markers at MAX_MARKERS.
* @param {Ace.MarkerGroupItem[]} markers an array of marker definitions.
*/
setMarkers(markers) {
this.markers = markers.sort(this.markersComparator).slice(0, this.MAX_MARKERS);
this.session._signal("changeBackMarker");
}

update(html, markerLayer, session, config) {
if (!this.markers || !this.markers.length)
return;
var visibleRangeStartRow = config.firstRow, visibleRangeEndRow = config.lastRow;
var foldLine;
var markersOnOneLine = 0;
var lastRow = 0;

for (var i = 0; i < this.markers.length; i++) {
var marker = this.markers[i];

if (marker.range.end.row < visibleRangeStartRow) continue;
if (marker.range.start.row > visibleRangeEndRow) continue;

if (marker.range.start.row === lastRow) {
markersOnOneLine++;
} else {
lastRow = marker.range.start.row;
markersOnOneLine = 0;
}
// do not render too many markers on one line
// because we do not have virtual scroll for horizontal direction
if (markersOnOneLine > 200) {
continue;
}

var markerVisibleRange = marker.range.clipRows(visibleRangeStartRow, visibleRangeEndRow);
if (markerVisibleRange.start.row === markerVisibleRange.end.row
&& markerVisibleRange.start.column === markerVisibleRange.end.column) {
continue; // visible range is empty
}

var screenRange = markerVisibleRange.toScreenRange(session);
if (screenRange.isEmpty()) {
// we are inside a fold
foldLine = session.getNextFoldLine(markerVisibleRange.end.row, foldLine);
if (foldLine && foldLine.end.row > markerVisibleRange.end.row) {
visibleRangeStartRow = foldLine.end.row;
}
continue;
}

if (screenRange.isMultiLine()) {
markerLayer.drawTextMarker(html, screenRange, marker.className, config);
} else {
markerLayer.drawSingleLineMarker(html, screenRange, marker.className, config);
}
}
}

}

// this caps total amount of markers at 10K
MarkerGroup.prototype.MAX_MARKERS = 10000;

exports.MarkerGroup = MarkerGroup;

75 changes: 75 additions & 0 deletions src/marker_group_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
if (typeof process !== "undefined") {
require("./test/mockdom");
}

"use strict";

var ace = require("./ace");
var dom = require("./lib/dom");
var assert = require("./test/assertions");
var EditSession = require("./edit_session").EditSession;
var Range = require("./range").Range;
var MarkerGroup = require("./marker_group").MarkerGroup;
var editor;
var session1, session2;

module.exports = {
setUp: function(next) {
var value = "Hello empty world\n"
+ "This is a second line"
+ "\n".repeat(100)
+ "line number 100";
session1 = new EditSession(value);
session2 = new EditSession("2 " + value);
editor = ace.edit(null, {
session: session1
});
document.body.appendChild(editor.container);
editor.container.style.height = "200px";
editor.container.style.width = "300px";
dom.importCssString('.ace_tooltip-marker_test { position: absolute; }');

next();
},
"test: show markers": function() {
editor.resize(true);
editor.renderer.$loop._flush();
var markerGroup = new MarkerGroup(session1);

markerGroup.setMarkers([{
range: new Range(0, 0, 0, 5),
className: "ace_tooltip-marker_test m2"
}, {
range: new Range(0, 12, 1, 4),
className: "ace_tooltip-marker_test m1",
isSecond: true
}]);
assert.ok(markerGroup.getMarkerAtPosition({row: 1, column: 1}).isSecond);
assert.ok(!markerGroup.getMarkerAtPosition({row: 3, column: 1}));
editor.renderer.$loop._flush();
assert.equal(editor.container.querySelectorAll(".m1").length, 2);
assert.equal(editor.container.querySelectorAll(".m2").length, 1);
editor.setSession(session2);
editor.renderer.$loop._flush();
assert.equal(editor.container.querySelectorAll(".m1").length, 0);
editor.setSession(session1);
editor.renderer.$loop._flush();
assert.equal(editor.container.querySelectorAll(".m1").length, 2);
editor.execCommand("gotoend");
editor.renderer.$loop._flush();
assert.equal(editor.container.querySelectorAll(".m1").length, 0);
editor.execCommand("gotostart");
editor.renderer.$loop._flush();
assert.equal(editor.container.querySelectorAll(".m1").length, 2);
markerGroup.setMarkers([]);
editor.renderer.$loop._flush();
assert.equal(editor.container.querySelectorAll(".m1").length, 0);
},
tearDown: function() {
editor.destroy();
}
};

if (typeof module !== "undefined" && module === require.main) {
require("asyncjs").test.testcase(module.exports).exec();
}
Loading

0 comments on commit 01d4605

Please sign in to comment.