/**
* Copyright (c) 2000-present Liferay, Inc. All rights reserved.
*
* This library is free software; you can redistribute it and/or modify it under
* the terms of the GNU Lesser General Public License as published by the Free
* Software Foundation; either version 2.1 of the License, or (at your option)
* any later version.
*
* This library is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*/
import Component from 'metal-component';
import Soy from 'metal-soy';
import {Config} from 'metal-state';
import templates from './AceEditor.soy';
/**
* @param {...any} args
*/
const FragmentAutocompleteProcessor = function(...args) {
FragmentAutocompleteProcessor.superclass.constructor.apply(this, args);
};
const MATCH_RESOURCE = 'resource';
const MATCH_TAG = 'tag';
const MATCH_TAGLIB = 'taglib';
const MATCH_VARIABLE = 'variable';
/**
* Creates an Ace Editor component to use for code editing.
*/
class AceEditor extends Component {
/**
* @inheritDoc
*/
attached() {
this._editorDocument = null;
this._editorSession = null;
this._getAutocompleteSuggestion = this._getAutocompleteSuggestion.bind(
this
);
this._getAutocompleteResults = this._getAutocompleteResults.bind(this);
this._handleDocumentChanged = this._handleDocumentChanged.bind(this);
AUI().use(
'aui-ace-editor',
'aui-ace-autocomplete-plugin',
'aui-ace-autocomplete-templateprocessor',
A => {
this._editor = new A.AceEditor({
boundingBox: this.refs.wrapper,
highlightActiveLine: false,
mode: this.syntax,
tabSize: 2,
});
this._editor.set('readOnly', this.readOnly);
this._editorDocument = this._editor.getSession().getDocument();
this._editorSession = this._editor.getSession();
this._overrideSetAnnotations(this._editorSession);
this.refs.wrapper.style.height = '';
this.refs.wrapper.style.width = '';
this._editorDocument.on('change', this._handleDocumentChanged);
this._editorSession.on(
'changeAnnotation',
this._handleDocumentChanged
);
if (this.initialContent) {
this._editorDocument.setValue(this.initialContent);
}
this._initAutocomplete(A, this._editor);
}
);
}
/**
* @inheritDoc
*/
disposed() {
if (this._editor) {
this._editor.destroy();
}
}
/**
* @inheritDoc
*/
shouldUpdate() {
return false;
}
/**
* @param {object} A
* @param {object} editor
* @private
* @review
*/
_initAutocomplete(A, editor) {
if (this.syntax !== 'html') {
return;
}
A.extend(
FragmentAutocompleteProcessor,
A.AceEditor.TemplateProcessor,
{
getMatch: this._getAutocompleteMatch,
getResults: this._getAutocompleteResults,
getSuggestion: this._getAutocompleteSuggestion,
}
);
const autocompleteProcessor = new FragmentAutocompleteProcessor({});
editor.plug(A.Plugin.AceAutoComplete, {
processor: autocompleteProcessor,
render: true,
visible: false,
zIndex: 10000,
});
}
/**
* Returns a match object (if any) for <code>lfr-</code> tags inside the
* given content.
*
* @param {string} content The given content.
* @private
* @return {object} The matching result.
*/
_getAutocompleteMatch(content) {
let match = null;
let matchContent = content;
if (
matchContent.indexOf('<') >= 0 ||
matchContent.indexOf('${') >= 0 ||
matchContent.indexOf('[@') >= 0 ||
matchContent.indexOf('[resources:') >= 0
) {
matchContent = matchContent.trim();
if (/.*?\[resources:[^\]]*$/.test(matchContent)) {
const start = '[resources:';
const index = matchContent.indexOf(start) + start.length;
match = {
content: matchContent.substring(index),
type: MATCH_RESOURCE,
};
}
else if (/<lfr[\w]*[^<lfr]*$/.test(matchContent)) {
const start = '<lfr';
const index = matchContent.indexOf(start) + start.length;
match = {
content: matchContent.substring(index),
index,
type: MATCH_TAG,
};
}
else if (/\[@[^\]]*$/.test(matchContent)) {
const start = '[@';
const index = matchContent.indexOf(start) + start.length;
match = {
content: matchContent.substring(index),
type: MATCH_TAGLIB,
};
}
else if (/.*?\${[^}]*$/.test(matchContent)) {
const start = '${';
const index = matchContent.indexOf(start) + start.length;
match = {
content: matchContent.substring(index),
type: MATCH_VARIABLE,
};
}
}
return match;
}
/**
* Returns a list of available auto-complete suggestions for the given
* match.
*
*
* @param {object} match The match.
* @param {function} callbackSuccess Success callback.
* @param {function} callbackError Errir callback.
* @private
* @return {array} The list of available suggestions.
*/
_getAutocompleteResults(match, callbackSuccess, callbackError) {
let matchDirectives = null;
const escapedContent = match.content.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
const regex = new RegExp(escapedContent || '', 'i');
if (match.type === MATCH_RESOURCE) {
matchDirectives = this.resources;
}
else if (match.type === MATCH_TAG) {
matchDirectives = this.autocompleteTags.map(tag => tag.name);
}
else if (match.type === MATCH_TAGLIB) {
matchDirectives = this.freeMarkerTaglibs;
}
else if (match.type === MATCH_VARIABLE) {
matchDirectives = this.freeMarkerVariables;
}
else {
callbackError();
}
if (matchDirectives) {
matchDirectives = matchDirectives.filter(directive =>
regex.test(directive)
);
callbackSuccess(matchDirectives);
}
}
/**
* Returns a tag completion suggestion for the given match and selected
* suggestion.
*
* @param {object} match The match.
* @param {string} selectedSuggestion The selected suggestion.
* @private
* @return {string} The suggested tag autocompletion.
*/
_getAutocompleteSuggestion(match, selectedSuggestion) {
let result = this.autocompleteTags.find(
_tag => _tag.name === selectedSuggestion
);
if (result) {
result = result.content.substring(match.index);
}
if (!result) {
result = [
...this.freeMarkerTaglibs,
...this.freeMarkerVariables,
...this.resources,
].find(variable => variable === selectedSuggestion);
if (result && match.type === MATCH_RESOURCE) {
result += ']';
}
}
return result || '';
}
/**
* Callback executed when the internal Ace Editor is modified; this
* propagates the <code>contentChanged</code> event.
*
* @private
*/
_handleDocumentChanged() {
const valid = this._editorSession
.getAnnotations()
.reduce((acc, annotation) => {
return !acc || annotation.type === 'error' ? false : acc;
}, true);
this.emit('contentChanged', {
content: this._editorDocument.getValue(),
valid,
});
}
/**
* Overrides Ace Editor's session <code>setAnnotations</code> method to avoid
* showing misleading messages.
*
* @param {Object} session AceEditor session
* @private
*/
_overrideSetAnnotations(session) {
const setAnnotations = session.setAnnotations.bind(session);
session.setAnnotations = () => {
setAnnotations([]);
};
}
}
/**
* Available Ace Editor syntax.
*
* @static
* @type {Object}
*/
AceEditor.SYNTAX = {
css: 'css',
html: 'html',
javascript: 'javascript',
json: 'json',
};
/**
* State definition.
*
* @static
* @type {!Object}
*/
AceEditor.STATE = {
/**
* Ace editor plugin instance
* @default null
* @instance
* @memberof AceEditor
* @type object
*/
_editor: Config.object()
.internal()
.value(null),
/**
* Ace editor plugin document instance
* @default null
* @instance
* @memberof AceEditor
* @type object
*/
_editorDocument: Config.object()
.internal()
.value(null),
/**
* Ace editor plugin session instance
* @default null
* @instance
* @memberof AceEditor
* @type object
*/
_editorSession: Config.object()
.internal()
.value(null),
/**
* List of tags for custom autocompletion in the HTML editor.
*
* @default []
* @instance
* @memberOf AceEditor
* @type Array
*/
autocompleteTags: Config.arrayOf(
Config.shapeOf({
attributes: Config.arrayOf(Config.string()),
name: Config.string(),
})
),
/**
* List of FreeMarker tags for custom autocompletion in the HTML editor.
*
* @default []
* @instance
* @memberOf AceEditor
* @type Array
*/
freeMarkerTaglibs: Config.arrayOf(Config.string()),
/**
* List of FreeMarker variables for custom autocompletion in the HTML
* editor.
*
* @default []
* @instance
* @memberOf AceEditor
* @type Array
*/
freeMarkerVariables: Config.arrayOf(Config.string()),
/**
* Initial content sent to the editor.
*
* @default ''
* @instance
* @memberOf AceEditor
* @type {string}
*/
initialContent: Config.string().value(''),
/**
* Sets the editor in readOnly mode preventing any input from the user.
*
* @default undefined
* @instance
* @memberOf AceEditor
* @type {boolean}
*/
readOnly: Config.bool().required(),
/**
* List of resource names custom autocompletion in the HTML editor.
*
* @default []
* @instance
* @memberOf AceEditor
* @type Array
*/
resources: Config.arrayOf(Config.string()),
/**
* Syntax used for the Ace Editor that is rendered on the interface.
*
* @default undefined
* @instance
* @memberOf AceEditor
* @see {@link AceEditor.SYNTAX|SYNTAX}
* @type {!string}
*/
syntax: Config.oneOf(Object.values(AceEditor.SYNTAX)).required(),
};
Soy.register(AceEditor, templates);
export {AceEditor};
export default AceEditor;