gristlabs_grist-core/app/client/components/AceEditor.js

295 lines
10 KiB
JavaScript
Raw Normal View History

var ace = require('brace');
var _ = require('underscore');
// Used to load python language settings and color themes
require('brace/mode/python');
require('brace/theme/chrome');
require('brace/theme/dracula');
require('brace/ext/language_tools');
var {setupAceEditorCompletions} = require('./AceEditorCompletions');
var {getGristConfig} = require('../../common/urlUtils');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var modelUtil = require('../models/modelUtil');
/**
* A class to help set up the ace editor with standard formatting and convenience functions
* @param {Observable} options.observable: If given, creates a 2-way binding between the observable
* and the value of the editor.
* @param {Boolean} options.saveValueOnBlurEvent: Flag to indicate whether ace editor
* should save the value on `blur` event.
* @param {Function} options.calcSize: Optional function used to resize the editor. It is called
* with (elem, desiredSize) as arguments, and should return the actual size to use for the
* element. Both desiredSize and the return value are objects with 'width' and 'height' members.
*/
function AceEditor(options) {
options = options || {};
// Observable subscription is not created until the dom is built
this.observable = options.observable || null;
this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);
this.calcSize = options.calcSize || ((_elem, size) => size);
this.gristDoc = options.gristDoc || null;
this.column = options.column || null;
this.editorState = options.editorState || null;
this._readonly = options.readonly || false;
this.editor = null;
this.editorDom = null;
this.session = null;
this._setupCallback = null;
this._setupTimer = null;
this.textPadding = 10; // Space after cursor when not using wrap mode
}
dispose.makeDisposable(AceEditor);
// Builds editor dom with additional setup possible in function `optSetupCallback`.
// May be called multiple times by an instance of AceEditor.
AceEditor.prototype.buildDom = function(optSetupCallback) {
this._fullDom = dom('div.code_editor_container',
this.editorDom = dom('div')
);
this._setupCallback = optSetupCallback;
this._setupTimer = setTimeout(() => this._setup(), 0);
return this._fullDom;
};
/**
* You may optionally call this once the DOM returned from buildDom is attached to the document to
* make setup and sizing more immediate.
*/
AceEditor.prototype.onAttach = function() {
if (this._setupTimer) {
clearTimeout(this._setupTimer);
this._setupTimer = null;
this._setup();
}
};
AceEditor.prototype.writeObservable = function() {
if (this.observable) {
modelUtil.setSaveValue(this.observable, this.getValue());
}
};
AceEditor.prototype.getEditor = function() {
return this.editor;
};
AceEditor.prototype.getValue = function() {
return this.editor && this.editor.getValue();
};
/**
* @param {String} val: The new value to set the editor to.
* @param {Number} optCursorPos: Position where to place the cursor: at the end if omitted.
*/
AceEditor.prototype.setValue = function(val, optCursorPos) {
// Note that underlying setValue() has a special meaning for second parameter:
// undefined or 0 is selectAll, -1 is at the document start, and 1 is at the end.
this.editor.setValue(val, optCursorPos === 0 ? -1 : 1);
if (optCursorPos > 0 && optCursorPos < val.length) {
var pos = this.session.getDocument().indexToPosition(optCursorPos);
this.editor.moveCursorTo(pos.row, pos.column);
}
};
AceEditor.prototype.isBuilt = function() {
return this.editor !== null;
};
// Enables or disables the AceEditor
AceEditor.prototype.enable = function(bool) {
var editor = this.editor;
editor.setReadOnly(!bool);
editor.renderer.$cursorLayer.element.style.opacity = bool ? 100 : 0;
editor.gotoLine(Infinity, Infinity); // Prevents text selection on disable
};
/**
* Commands must be added specially to the ace editor.
* Attaching commands to the textarea using commandGroup.attach() only
* works for certain keys.
*
* Note: Commands to the aceEditor are always enabled.
* Note: Ace defers to standard behavior when false is returned.
*/
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
_.each(commandGroup.knownKeys, (command, key) => {
this.editor.commands.addCommand({
name: command,
// We are setting readonly as true to enable all commands
// in a readonly mode.
// Because FieldEditor in readonly mode will rewire all commands that
// modify state, we are safe to enable them.
readOnly: this._readonly,
bindKey: {
win: key,
mac: key,
sender: 'editor|cli'
},
// AceEditor wants a command to return true if it got handled, whereas our command returns
// true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.
exec: () => !commandGroup.commands[command]()
});
});
};
/**
* Attaches a command to the editor which saves the current editor
* contents to the attached observable on 'Shift+Enter'.
* Throws error if there is no attached observable.
* TODO: Use instead of custom save command for more implementations of AceEditor
*/
AceEditor.prototype.attachSaveCommand = function() {
if (!this.observable) {
throw new Error("Cannot attach save command to editor with no bound observable");
}
this.editor.commands.addCommand({
name: 'saveFormula',
bindKey: {
sender: 'editor|cli'
},
// AceEditor wants a command to return true if it got handled
exec: () => {
this.writeObservable();
return true;
}
});
};
// Wraps words to the current width of the editor
AceEditor.prototype.adjustContentToWidth = function() {
var characterWidth = this.editor.renderer.characterWidth;
var contentWidth = this.editor.renderer.scroller.clientWidth;
if(contentWidth > 0) {
this.editor.getSession().setWrapLimit(parseInt(contentWidth/characterWidth, 10) - 1);
}
};
/**
* Provides opportunity to execute some functionality when value in the editor has changed.
* Happens every time user types something to the control.
*/
AceEditor.prototype.onChange = function() {
if (this.editorState) this.editorState.set(this.getValue());
this.resize();
};
AceEditor.prototype.setFontSize = function(pxVal) {
this.editor.setFontSize(pxVal);
this.resize();
};
AceEditor.prototype._setup = function() {
// Standard editor setup
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
if (this.gristDoc && this.column) {
const getSuggestions = (prefix) => {
(core) Show example values in formula autocomplete Summary: This diff adds a preview of the value of certain autocomplete suggestions, especially of the form `$foo.bar` or `user.email`. The main initial motivation was to show the difference between `$Ref` and `$Ref.DisplayCol`, but the feature is more general. The client now sends the row ID of the row being edited (along with the table and column IDs which were already sent) to the server to fetch autocomplete suggestions. The returned suggestions are now tuples `(suggestion, example_value)` where `example_value` is a string or null. The example value is simply obtained by evaluating (in a controlled way) the suggestion in the context of the given record and the current user. The string representation is similar to the standard `repr` but dates and datetimes are formatted, and the whole thing is truncated for efficiency. The example values are shown in the autocomplete popup separated from the actual suggestion by a number of spaces calculated to: 1. Clearly separate the suggestion from the values 2. Left-align the example values in most cases 3. Avoid having so much space such that connecting suggestions and values becomes visually difficult. The tokenization of the row is then tweaked to show the example in light grey to deemphasise it. Main discussion where the above was decided: https://grist.slack.com/archives/CDHABLZJT/p1661795588100009 The diff also includes various other small improvements and fixes: - The autocomplete popup is much wider to make room for long suggestions, particularly lookups, as pointed out in https://phab.getgrist.com/D3580#inline-41007. The wide popup is the reason a fancy solution was needed to position the example values. I didn't see a way to dynamically resize the popup based on suggestions, and it didn't seem like a good idea to try. - The `grist` and `python` labels previously shown on the right are removed. They were not helpful (https://grist.slack.com/archives/CDHABLZJT/p1659697086155179) and would get in the way of the example values. - Fixed a bug in our custom tokenization that caused function arguments to be weirdly truncated in the middle: https://grist.slack.com/archives/CDHABLZJT/p1661956353699169?thread_ts=1661953258.342739&cid=CDHABLZJT and https://grist.slack.com/archives/C069RUP71/p1659696778991339 - Hide suggestions involving helper columns like `$gristHelper_Display` or `Table.lookupRecords(gristHelper_Display=` (https://grist.slack.com/archives/CDHABLZJT/p1661953258342739). The former has been around for a while and seems to be a mistake. The fix is simply to use `is_visible_column` instead of `is_user_column`. Since the latter is not used anywhere else, and using it in the first place seems like a mistake more than anything else, I've also removed the function to prevent similar mistakes in the future. - Don't suggest private columns as lookup arguments: https://grist.slack.com/archives/CDHABLZJT/p1662133416652499?thread_ts=1661795588.100009&cid=CDHABLZJT - Only fetch fresh suggestions specifically after typing `lookupRecords(` or `lookupOne(` rather than just `(`, as this would needlessly hide function suggestions which could still be useful to see the arguments. However this only makes a difference when there are still multiple matching suggestions, otherwise Ace hides them anyway. Test Plan: Extended and updated several Python and browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3611
2022-09-28 14:47:55 +00:00
const section = this.gristDoc.viewModel.activeSection();
const tableId = section.table().tableId();
const columnId = this.column.colId();
(core) Show example values in formula autocomplete Summary: This diff adds a preview of the value of certain autocomplete suggestions, especially of the form `$foo.bar` or `user.email`. The main initial motivation was to show the difference between `$Ref` and `$Ref.DisplayCol`, but the feature is more general. The client now sends the row ID of the row being edited (along with the table and column IDs which were already sent) to the server to fetch autocomplete suggestions. The returned suggestions are now tuples `(suggestion, example_value)` where `example_value` is a string or null. The example value is simply obtained by evaluating (in a controlled way) the suggestion in the context of the given record and the current user. The string representation is similar to the standard `repr` but dates and datetimes are formatted, and the whole thing is truncated for efficiency. The example values are shown in the autocomplete popup separated from the actual suggestion by a number of spaces calculated to: 1. Clearly separate the suggestion from the values 2. Left-align the example values in most cases 3. Avoid having so much space such that connecting suggestions and values becomes visually difficult. The tokenization of the row is then tweaked to show the example in light grey to deemphasise it. Main discussion where the above was decided: https://grist.slack.com/archives/CDHABLZJT/p1661795588100009 The diff also includes various other small improvements and fixes: - The autocomplete popup is much wider to make room for long suggestions, particularly lookups, as pointed out in https://phab.getgrist.com/D3580#inline-41007. The wide popup is the reason a fancy solution was needed to position the example values. I didn't see a way to dynamically resize the popup based on suggestions, and it didn't seem like a good idea to try. - The `grist` and `python` labels previously shown on the right are removed. They were not helpful (https://grist.slack.com/archives/CDHABLZJT/p1659697086155179) and would get in the way of the example values. - Fixed a bug in our custom tokenization that caused function arguments to be weirdly truncated in the middle: https://grist.slack.com/archives/CDHABLZJT/p1661956353699169?thread_ts=1661953258.342739&cid=CDHABLZJT and https://grist.slack.com/archives/C069RUP71/p1659696778991339 - Hide suggestions involving helper columns like `$gristHelper_Display` or `Table.lookupRecords(gristHelper_Display=` (https://grist.slack.com/archives/CDHABLZJT/p1661953258342739). The former has been around for a while and seems to be a mistake. The fix is simply to use `is_visible_column` instead of `is_user_column`. Since the latter is not used anywhere else, and using it in the first place seems like a mistake more than anything else, I've also removed the function to prevent similar mistakes in the future. - Don't suggest private columns as lookup arguments: https://grist.slack.com/archives/CDHABLZJT/p1662133416652499?thread_ts=1661795588.100009&cid=CDHABLZJT - Only fetch fresh suggestions specifically after typing `lookupRecords(` or `lookupOne(` rather than just `(`, as this would needlessly hide function suggestions which could still be useful to see the arguments. However this only makes a difference when there are still multiple matching suggestions, otherwise Ace hides them anyway. Test Plan: Extended and updated several Python and browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3611
2022-09-28 14:47:55 +00:00
const rowId = section.activeRowId();
return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);
};
setupAceEditorCompletions(this.editor, {getSuggestions});
}
this.editor.setOptions({
enableLiveAutocompletion: true, // use autocompletion without needing special activation.
});
this.session = this.editor.getSession();
this.session.setMode('ace/mode/python');
const gristTheme = this.gristDoc?.docPageModel.appModel.currentTheme;
this._setAceTheme(gristTheme?.get());
if (!getGristConfig().enableCustomCss && gristTheme) {
this.autoDispose(gristTheme.addListener((theme) => {
this._setAceTheme(theme);
}));
}
// Default line numbers to hidden
this.editor.renderer.setShowGutter(false);
this.session.setTabSize(2);
this.session.setUseWrapMode(true);
this.editor.on('change', this.onChange.bind(this));
this.editor.$blockScrolling = Infinity;
this.editor.setFontSize(11);
this.resize();
// Set up the bound observable if supplied
if (this.observable) {
var subscription = this.observable.subscribeInit(val => {if (val !== undefined) {this.setValue(val);}});
// Dispose with dom since subscription is created when dom is created
dom(this.editorDom,
dom.autoDispose(subscription)
);
if (this.saveValueOnBlurEvent) {
this.editor.on('blur', () => {
this.writeObservable();
});
}
}
if (this._setupCallback) {
this._setupCallback.call(null, this.editor);
this._setupCallback = null;
}
};
AceEditor.prototype.resize = function() {
var wrap = this.session.getUseWrapMode();
var contentWidth = wrap ? 0 : this._getContentWidth();
var contentHeight = this._getContentHeight();
var desiredSize = {
width: wrap ? 0 : contentWidth + this.textPadding,
height: contentHeight,
};
var size = this.calcSize(this._fullDom, desiredSize);
if (size.height < contentHeight) {
// Editor will show a vertical scrollbar, so recalculate to make space for it.
desiredSize.width += 20;
size = this.calcSize(this._fullDom, desiredSize);
}
if (size.width < contentWidth) {
// Editor will show a horizontal scrollbar, so recalculate to make space for it.
desiredSize.height += 20;
size = this.calcSize(this._fullDom, desiredSize);
}
// Setting height or width to number like 100.00000005 won't work (it will be truncated).
// Unfortunately ace editor will do the same math we do, and will expect the height or width
// of the container to be 100.0000005, and when it finds out that it is only 100px will show
// scrollbars. To fix this issue we will make the container a little bit bigger.
// This won't help for zooming (where the same problem occurs but in many more places), but will
// help for Windows users who have different pixel ratio.
this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto';
this.editorDom.style.height = Math.ceil(size.height) + 'px';
this.editor.resize();
};
AceEditor.prototype._getContentWidth = function() {
return this.session.getScreenWidth() * this.editor.renderer.characterWidth;
};
AceEditor.prototype._getContentHeight = function() {
return Math.max(1, this.session.getScreenLength()) * this.editor.renderer.lineHeight;
};
AceEditor.prototype._setAceTheme = function(gristTheme) {
const {enableCustomCss} = getGristConfig();
const gristAppearance = gristTheme?.appearance;
const aceTheme = gristAppearance === 'dark' && !enableCustomCss ? 'dracula' : 'chrome';
this.editor.setTheme(`ace/theme/${aceTheme}`);
};
let _RangeConstructor = null; //singleton, load it lazily
AceEditor.makeRange = function(a, b, c, d) {
_RangeConstructor = _RangeConstructor || ace.acequire('ace/range').Range;
return new _RangeConstructor(a, b, c, d);
};
module.exports = AceEditor;