novo-elements
Version:
Bullhorn's NOVO Element Repository for Angular 2
540 lines • 24.2 kB
JavaScript
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return function (d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
// NG2
var core_1 = require("@angular/core");
var forms_1 = require("@angular/forms");
// APP
var OutsideClick_1 = require("./../../utils/outside-click/OutsideClick");
var KeyCodes_1 = require("./../../utils/key-codes/KeyCodes");
var QuickNoteResults_1 = require("./extras/quick-note-results/QuickNoteResults");
var ComponentUtils_1 = require("./../../utils/component-utils/ComponentUtils");
// Value accessor for the component (supports ngModel)
var QUICK_NOTE_VALUE_ACCESSOR = {
provide: forms_1.NG_VALUE_ACCESSOR,
useExisting: core_1.forwardRef(function () { return QuickNoteElement; }),
multi: true
};
var QuickNoteElement = (function (_super) {
__extends(QuickNoteElement, _super);
function QuickNoteElement(zone, element, componentUtils) {
var _this = _super.call(this, element) || this;
_this.zone = zone;
_this.componentUtils = componentUtils;
// Emitter for selects
_this.focus = new core_1.EventEmitter();
_this.blur = new core_1.EventEmitter();
_this.change = new core_1.EventEmitter();
_this.placeholderVisible = false;
_this._placeholderElement = null;
_this.onModelChange = function () {
};
_this.onModelTouched = function () {
};
// Bind to the active change event from the OutsideClick
_this.onActiveChange.subscribe(function (active) {
if (!active) {
setTimeout(function () {
_this.hideResults();
});
}
});
return _this;
}
QuickNoteElement.prototype.ngOnInit = function () {
// Make sure we have a proper config
if (!this.config) {
throw new Error('No config set for QuickNote!');
}
// Make sure that we have triggers
if (!this.config.triggers) {
throw new Error('QuickNote config must supply triggers!');
}
// Make sure that we have options
if (!this.config.options) {
throw new Error('QuickNote config must supply options!');
}
// Allow for callers to use a custom results template class in the config
this.resultsComponent = this.config.resultsTemplate || QuickNoteResults_1.QuickNoteResults;
};
QuickNoteElement.prototype.ngOnDestroy = function () {
var _this = this;
// Tear down the CKEditor instance
if (this.ckeInstance) {
this.ckeInstance.focusManager.blur(true); // Remove focus from editor
setTimeout(function () {
_this.ckeInstance.removeAllListeners();
CKEDITOR.instances[_this.ckeInstance.name].destroy();
_this.ckeInstance.destroy();
_this.ckeInstance = null;
});
}
};
/**
* Connect to key/mouse events from CKEditor after the editor has been initialized
*/
QuickNoteElement.prototype.ngAfterViewInit = function () {
var _this = this;
if (!CKEDITOR) {
console.error('Make sure to include CKEditor sources in your dependencies!');
return;
}
// Replace the textarea with an instance of CKEditor
this.ckeInstance = CKEDITOR.replace(this.host.nativeElement, this.getCKEditorConfig());
// Set initial value of the note in the editor
this.writeValue(this.model);
// Connect to the key event in CKEditor for showing results dropdown
this.ckeInstance.on('key', function (event) {
if (!_this.onKey(event.data.domEvent.$)) {
event.cancel();
}
});
// Connect to the change event in CKEditor for debouncing user modifications
this.ckeInstance.on('change', function () {
// Debounce update
if (_this.debounceTimeout) {
clearTimeout(_this.debounceTimeout);
}
_this.debounceTimeout = setTimeout(function () {
// Run within the context of this angular element since we don't need to cancel event
_this.zone.run(function () {
_this.onValueChange();
});
_this.debounceTimeout = null;
}, 250);
});
// Propagate blur events from CKEditor to the Element's listeners
this.ckeInstance.on('blur', function (event) {
_this.showPlaceholder();
_this.blur.emit(event);
});
// Propagate blur events from CKEditor to the Element's listeners
this.ckeInstance.on('focus', function (event) {
_this.hidePlaceholder();
_this.focus.emit(event);
});
// Show placeholder if the note is empty, after the editor is instantiated
this.ckeInstance.on('instanceReady', function (event) {
_this.showPlaceholder();
});
};
// Set touched on blur
QuickNoteElement.prototype.onTouched = function (event) {
this.onModelTouched();
};
/**
* Handles setting the model and the view from the outside caller or the user's typing
*
* @param model A model that has a note (html content) and references (array of objects)
*/
QuickNoteElement.prototype.writeValue = function (model) {
// Set value of the model
if (model && (model.references || model.note)) {
this.model = {
note: model.note || '',
references: model.references || {}
};
}
else {
this.model = {
note: model,
references: {}
};
}
// Set the note html value in the editor
if (this.ckeInstance) {
this.ckeInstance.setData(this.model.note);
}
};
QuickNoteElement.prototype.registerOnChange = function (fn) {
this.onModelChange = fn;
};
QuickNoteElement.prototype.registerOnTouched = function (fn) {
this.onModelTouched = fn;
};
/**
* If a renderer is not provided, the QuickNote will default to using this one, an anchor tag with no href
*/
QuickNoteElement.defaultRenderer = function (symbol, item) {
return "<a>" + symbol + item.label + "</a>";
};
/**
* Returns the renderer for a given tagging mode if it exists in the config, otherwise the default.
*/
QuickNoteElement.prototype.getRenderer = function (taggingMode) {
return this.config.renderer ? this.config.renderer[taggingMode] : QuickNoteElement.defaultRenderer;
};
/**
* Called every time a keystroke is made in the editor. Listens for particular keys (e.g. UP arrow, ESC, etc.)
* to handle certain behaviors of the picker.
*
* Runs within the context of the CKEditor, so actions that affect the view have to be run back inside of the
* Angular zone of this class.
*
* @param event The key press event
* @return true to allow the event to occur, false to cancel the event
*/
QuickNoteElement.prototype.onKey = function (event) {
var _this = this;
if (event.key) {
if (this.quickNoteResults) {
// Hide results on escape key
if (event.keyCode === KeyCodes_1.KeyCodes.ESC) {
this.zone.run(function () {
_this.hideResults();
});
return false;
}
// Navigation inside the results
if (event.keyCode === KeyCodes_1.KeyCodes.UP) {
this.zone.run(function () {
_this.quickNoteResults.instance.prevActiveMatch();
});
return false;
}
if (event.keyCode === KeyCodes_1.KeyCodes.DOWN) {
this.zone.run(function () {
_this.quickNoteResults.instance.nextActiveMatch();
});
return false;
}
if (event.keyCode === KeyCodes_1.KeyCodes.ENTER) {
this.zone.run(function () {
_this.quickNoteResults.instance.selectActiveMatch();
});
return false;
}
}
else {
// Loop through all triggers and turn on tagging mode if the user just pressed a trigger character
var triggers_1 = this.config.triggers || {};
Object.keys(triggers_1).forEach(function (key) {
var trigger = triggers_1[key] || {};
if (event.key === trigger) {
_this.isTagging = true;
_this.taggingMode = key;
}
});
}
}
return true;
};
/**
* Debounced method that is run in the proper Angular context when the user has modified the CKEditor.
* After the value has been updated in CKEditor, this will propagate that change to the model and listeners.
*/
QuickNoteElement.prototype.onValueChange = function () {
// Get the html text in CKEditor
var value = this.ckeInstance.getData();
// Remove empty 'ZERO WIDTH SPACE' characters that can get added erroneously by the editor
var regex = new RegExp(String.fromCharCode(8203), 'g');
value = value.replace(regex, '');
// Make sure that any references in the model are still valid
this.validateReferences();
// Possibly show results if the user has entered a search term
this.showResults();
// Propagate change to ngModel for form validation, and send null if the note is empty
var newModel = null;
if (value) {
newModel = {
note: value,
references: this.model.references
};
}
// Inform listeners to the ngModel change event that something has changed
this.onModelChange(newModel);
// Inform listeners of the `@Output() change` event that the model has been updated
this.change.emit(newModel);
// Inform listeners to the ngModel touched event that something has changed
this.onTouched();
};
/**
* Creates an instance of the results (called popup) and adds all the bindings to that instance.
*/
QuickNoteElement.prototype.showResults = function () {
if (this.isTagging) {
var searchTerm = this.getSearchTerm();
if (searchTerm.length) {
// Update Matches
if (this.quickNoteResults) {
// Update existing list
this.quickNoteResults.instance.term = {
searchTerm: searchTerm,
taggingMode: this.taggingMode
};
}
else {
// Create the results DOM element
this.quickNoteResults = this.componentUtils.appendNextToLocation(this.resultsComponent, this.results);
this.quickNoteResults.instance.parent = this;
this.quickNoteResults.instance.config = this.config;
this.quickNoteResults.instance.term = {
searchTerm: searchTerm,
taggingMode: this.taggingMode
};
this.positionResultsDropdown();
}
}
else if (this.quickNoteResults) {
this.quickNoteResults.destroy();
this.quickNoteResults = null;
}
// Tell the OutsideClick base class to start listening for an outside clicks
this.toggleActive(null, true);
}
};
/**
* Deletes the picker results from the DOM.
*/
QuickNoteElement.prototype.hideResults = function () {
this.isTagging = false;
if (this.quickNoteResults) {
this.quickNoteResults.destroy();
this.quickNoteResults = null;
}
};
/**
* Handles the selection from the QuickNoteResults Component. Called by the QuickNoteResults component on it's
* parent (this element).
*
* @param taggingMode - type of tags we are looking for
* @param selected - selected object from the picker that has a label and value
*/
QuickNoteElement.prototype.onSelected = function (taggingMode, selected) {
// Turn off tagging
this.isTagging = false;
// Replace searchTerm with link
var symbol = this.config.triggers[taggingMode];
var renderer = this.getRenderer(taggingMode);
var renderedText = renderer(symbol, selected);
this.replaceWordAtCursor(renderedText);
// Add the new reference, if it doesn't already exist
this.model.references = this.model.references || {};
this.model.references[taggingMode] = this.model.references[taggingMode] || [];
var matchingItems = this.model.references[taggingMode].filter(function (item) { return JSON.stringify(item) === JSON.stringify(selected); });
if (matchingItems.length === 0) {
this.model.references[taggingMode].push(selected);
}
// Update the quick note with the changes due to the user's selection of an item in the dropdown
this.onValueChange();
};
/**
* Convenience method that gets the current word that the cursor is on, minus the tag.
* Also, trims any whitespace before/after the term to aid in searching.
*/
QuickNoteElement.prototype.getSearchTerm = function () {
var word = this.getWordAtCursor().trim();
if (this.isTagging) {
var symbol = this.config.triggers[this.taggingMode];
if (!word.includes(symbol)) {
this.hideResults();
return '';
}
word = word.slice(word.indexOf(symbol) + symbol.length);
}
return word;
};
/**
* Gets the current word that the cursor is on CKEditor. Current word starts at the beginning of the line or a
* tag character if we are in tagging mode. Current word ends at the end of the line or an empty space.
*
* @returns plain text string (removes all html formatting)
*/
QuickNoteElement.prototype.getWordAtCursor = function () {
var range = this.ckeInstance.getSelection().getRanges()[0];
var start = range.startContainer;
if (start.type === CKEDITOR.NODE_TEXT && range.startOffset) {
var text = start.getText();
var symbol = this.config.triggers[this.taggingMode];
var wordStart = text.lastIndexOf(symbol, range.startOffset - 1);
var wordEnd = text.indexOf(' ', range.startOffset + 1);
if (wordStart === -1) {
wordStart = 0;
}
if (wordEnd === -1) {
wordEnd = text.length;
}
return text.substring(wordStart, wordEnd);
}
// Selection starts at the 0 index of the text node or there's no previous text node in contents
return '';
};
/**
* Replaces the word that the user is on with the given html.
*
* CKEditor gives us access to the current line of html in the editor, so we replace the content of
* the line, replacing only the current word.
*/
QuickNoteElement.prototype.replaceWordAtCursor = function (newWord) {
var originalWord = this.getWordAtCursor().trim();
var range = this.ckeInstance.getSelection().getRanges()[0];
var start = range.startContainer;
var parentNode = start.getParent();
if (start.type === CKEDITOR.NODE_TEXT && parentNode) {
var line = parentNode.getHtml();
var index = line.lastIndexOf(originalWord);
if (index >= 0) {
// Add a space after the replaced word so that multiple references can be added back to back
var newLine = line.substring(0, index) + newWord + ' ' + line.substring(index + originalWord.length);
parentNode.setHtml(newLine);
// Place selection at the end of the line
range.moveToPosition(parentNode, CKEDITOR.POSITION_BEFORE_END);
this.ckeInstance.getSelection().selectRanges([range]);
}
}
};
/**
* Returns current references, minus any from the model that have been removed from the editor.
*/
QuickNoteElement.prototype.validateReferences = function () {
var _this = this;
var html = this.ckeInstance.document.getBody().getHtml();
// CKEditor stopped supporting the config.forceSimpleAmpersand setting, so we have to convert '&' to '&'
// when we pull html from the editor - see: https://dev.ckeditor.com/ticket/13723
var ampRegex = new RegExp('&', 'g');
html = html.replace(ampRegex, '&');
Object.keys(this.model.references).forEach(function (taggingMode) {
var array = _this.model.references[taggingMode] || [];
var symbol = _this.config.triggers[taggingMode];
var renderer = _this.getRenderer(taggingMode);
_this.model.references[taggingMode] = array.filter(function (item) {
var renderedText = renderer(symbol, item);
return html.includes(renderedText);
});
// If no references, then delete the key
if (_this.model.references[taggingMode].length === 0) {
delete _this.model.references[taggingMode];
}
});
};
/**
* Configures the CKEditor for QuickNote functionality.
*
* Sets the height of the CKEditor dynamically to the height of the wrapper upon initialization.
* Removes the toolbar on the bottom and configures a slimmed down version of the toolbar.
* Removes plugins and turns off setting to allow browser based spell checking.
*/
QuickNoteElement.prototype.getCKEditorConfig = function () {
var editorHeight = this.wrapper.nativeElement.clientHeight - QuickNoteElement.TOOLBAR_HEIGHT;
return {
enterMode: CKEDITOR.ENTER_BR,
shiftEnterMode: CKEDITOR.ENTER_P,
disableNativeSpellChecker: false,
height: editorHeight,
removePlugins: 'elementspath,liststyle,tabletools,contextmenu',
resize_enabled: false,
toolbar: [{
name: 'basicstyles',
items: ['Styles', 'FontSize', 'Bold', 'Italic', 'Underline', 'TextColor', '-', 'NumberedList', 'BulletedList', 'Outdent', 'Indent', 'Link']
}]
};
};
/**
* Returns the current screen position of the cursor in CKEditor, accounting for any scrolling in the editor.
*
* @returns {{top: number, left: number}}
*/
QuickNoteElement.prototype.getCursorPosition = function () {
var range = this.ckeInstance.getSelection().getRanges()[0];
var parentElement = range.startContainer.$.parentElement;
var editorElement = this.ckeInstance.editable().$;
// Since the editor is a text node in the DOM that does not know about it's position, a temporary element has to
// be inserted in order to locate the cursor position.
var cursorElement = document.createElement('img');
cursorElement.setAttribute('src', 'null');
cursorElement.setAttribute('width', '0');
cursorElement.setAttribute('height', '0');
parentElement.appendChild(cursorElement);
var cursorPosition = {
top: cursorElement.offsetTop - editorElement.scrollTop,
left: cursorElement.offsetLeft - editorElement.scrollLeft
};
cursorElement.remove();
return cursorPosition;
};
/**
* Positions the results dropdown based on the location of the cursor in the text field
*/
QuickNoteElement.prototype.positionResultsDropdown = function () {
var DROPDOWN_OFFSET = 30; // The distance between the cursor and the dropdown
var MIN_MARGIN_TOP = DROPDOWN_OFFSET;
var MAX_MARGIN_TOP = this.ckeInstance.config.height + QuickNoteElement.TOOLBAR_HEIGHT;
var cursorPosition = this.getCursorPosition();
var marginTop = cursorPosition.top + QuickNoteElement.TOOLBAR_HEIGHT + DROPDOWN_OFFSET;
// Check that the margin is within the visible bounds
marginTop = Math.max(marginTop, MIN_MARGIN_TOP);
marginTop = Math.min(marginTop, MAX_MARGIN_TOP);
// Set the margin-top of the dropdown
this.quickNoteResults.instance.element.nativeElement.style.setProperty('margin-top', marginTop + 'px');
};
/**
* Show the placeholder text if the editor is empty
*/
QuickNoteElement.prototype.showPlaceholder = function () {
if (!this.ckeInstance.getData()) {
this.ckeInstance.editable().getParent().$.appendChild(this.placeholderElement);
this.placeholderVisible = true;
}
};
/**
* Hide the placeholder text by removing the placeholder element from the DOM
*/
QuickNoteElement.prototype.hidePlaceholder = function () {
if (this.placeholderVisible) {
this.ckeInstance.editable().getParent().$.removeChild(this.placeholderElement);
this.placeholderVisible = false;
}
};
Object.defineProperty(QuickNoteElement.prototype, "placeholderElement", {
/**
* Get or create the single placeholder object that is constructed only when needed.
*/
get: function () {
if (!this._placeholderElement) {
this._placeholderElement = document.createElement('div');
this._placeholderElement.className = 'placeholder';
this._placeholderElement.style.cssText = 'margin: 20px; color: #AAAAAA; font-family: sans-serif; font-size: 13px; line-height: 20px; position: absolute; top: 0';
this._placeholderElement.textContent = this.placeholder;
}
return this._placeholderElement;
},
enumerable: true,
configurable: true
});
return QuickNoteElement;
}(OutsideClick_1.OutsideClick));
QuickNoteElement.TOOLBAR_HEIGHT = 40; // in pixels - configured by stylesheet
QuickNoteElement.decorators = [
{ type: core_1.Component, args: [{
selector: 'novo-quick-note',
providers: [QUICK_NOTE_VALUE_ACCESSOR],
template: "\n <div class=\"quick-note-wrapper\" #wrapper>\n <textarea #host></textarea>\n <span #results></span>\n </div>\n "
},] },
];
/** @nocollapse */
QuickNoteElement.ctorParameters = function () { return [
{ type: core_1.NgZone, },
{ type: core_1.ElementRef, },
{ type: ComponentUtils_1.ComponentUtils, },
]; };
QuickNoteElement.propDecorators = {
'wrapper': [{ type: core_1.ViewChild, args: ['wrapper',] },],
'host': [{ type: core_1.ViewChild, args: ['host',] },],
'results': [{ type: core_1.ViewChild, args: ['results', { read: core_1.ViewContainerRef },] },],
'config': [{ type: core_1.Input },],
'placeholder': [{ type: core_1.Input },],
'focus': [{ type: core_1.Output },],
'blur': [{ type: core_1.Output },],
'change': [{ type: core_1.Output },],
};
exports.QuickNoteElement = QuickNoteElement;
//# sourceMappingURL=QuickNote.js.map