nodebb-widget-html-extended
Version:
Enhanced HTML widget for NodeBB with slider functionality
422 lines (379 loc) • 15.7 kB
JavaScript
/*
___ _ _ _____ _ _ ___ _____ _____ _____
|_ _| \| |_ _| | | |_ _|_ _|_ _\ \ / / __| TM
| || .` | | | | |_| || | | | | | \ V /| _|
|___|_|\_|_|_|__\___/|___|_|_|_|___| \_/_|___| __
| \| __| \/ |/ _ \ / __| _ \ /_\ / __\ \ / /
| |) | _|| |\/| | (_) | (__| / / _ \ (__ \ V /
|___/|___|_| |_|\___/ \___|_|_\/_/ \_\___| |_|
© Manuel Valle C. - AromaItaliano SA, Costa Rica
*/
'use strict';
/* globals utils, config, define, app, ajaxify, socket */
define('intuitivedemocracy/admin/html-extended', ['ace/ace', 'ace/ext/language_tools', 'selectize'], function (ace, langTools, selectize) {
var Widget = {};
Widget.instances = {};
Widget.templateData = {};
Widget.init = function(options) {
// Handle browser native exitFullscreen command ESC
if (screenfull.isEnabled) {
screenfull.on('change', event => {
$(event.target).find('.delete-widget').toggleClass('hidden', screenfull.isFullscreen);
$(event.target).closest('[data-widget]').find('pre').toggleClass('fullscreen', screenfull.isFullscreen);
$(event.target).closest('.widget-area.ui-sortable').sortable( screenfull.isFullscreen ? 'disable' : 'enable' );
Widget.instances[$(event.target).closest('[data-widget]').data('id')].editor.focus();
});
}
/*
* Events handlers
*/
// Loads respective data acording to selected template (context in which widget is inserted)
$('#widgets .nav-pills .dropdown-menu a').on('click', function (ev) {
$('#active-widgets div[data-widget="html-extended"] .panel-body .nav li>a[data-mode="html"]>i').toggleClass('hidden', false);
const route = $(this).attr('data-template').split('.')[0];
const key = '1';
// Retreives data from API route and add as a property in ajaxify.data object
loadTemplateData(route, key, function(err, data) {
Widget.templateData = data;
$('#active-widgets div[data-widget="html-extended"] .panel-body .nav li>a[data-mode="html"]>i').toggleClass('hidden', true);
});
})
$('#widgets .widget-area')
.on('click', '.expand-widget', event => {
if (screenfull.isEnabled) {
screenfull.toggle($(event.target).closest('[data-widget]')[0]);
}
})
.on('click', 'div[data-widget="html-extended"] > .panel-heading', function (evt) {
if ($(evt.target).hasClass('delete-widget') || $(evt.target).parent('.delete-widget').length) return;
var route = $(this).parents('[data-template]').data('template').split('.').shift();
var $panel = $(this).next('.panel-body');
var $widget = $panel.closest('[data-widget]');
var $editor = $panel.find('pre.ace-placeholder');
var $sortable = $widget.closest('.widget-area.ui-sortable');
var $settings = $widget.find('.settings-wrapper');
if (Object.keys(Widget.templateData).length) $('#active-widgets div[data-widget="html-extended"] .panel-body .nav li>a[data-mode="html"]>i').toggleClass('hidden', true);
if (!$panel.hasClass('hidden')) {
const UUID = utils.generateUUID();
const elementID = 'ace-editor-'+UUID
$editor.attr('id', elementID);
$widget.data('id', elementID);
Widget.instances[elementID] = {
editor: ace.edit($editor.get(0)),
sessions: {},
};
var aceEditor = Widget.instances[elementID].editor;
aceEditor.setTheme('ace/theme/tomorrow');
aceEditor.setOptions({
wrap: true,
enableBasicAutocompletion: true,
enableSnippets: false,
enableLiveAutocompletion: true
});
var EditSession = require("ace/edit_session").EditSession;
Widget.instances[elementID].sessions.html = new EditSession($editor.siblings('input[name="template"]').val());
Widget.instances[elementID].sessions.html.setMode('ace/mode/html');
Widget.instances[elementID].sessions.less = new EditSession($editor.siblings('input[name="less"]').val());
Widget.instances[elementID].sessions.less.setMode('ace/mode/less');
Widget.instances[elementID].sessions.json = new EditSession($editor.siblings('input[name="data"]').val());
Widget.templateData.slides = validateJsonData(Widget.instances[elementID].sessions.json.getValue());
$panel.parent().find('.nav a[data-mode="json"]>i').toggleClass('hidden', !Widget.templateData.slides.error);
Widget.instances[elementID].sessions.json.setMode('ace/mode/json');
// Ace Editor events handlers
aceEditor.on('change', function (e, o) {
app.flags = app.flags || {};
app.flags._unsaved = true;
if (o.getSession().$mode.$id.includes('html')) {
$editor.siblings('input[name="template"]').val(aceEditor.getValue());
} else if (o.getSession().$mode.$id.includes('json')) {
Widget.templateData.slides = validateJsonData(aceEditor.getValue());
$panel.parent().find('.nav a[data-mode="json"]>i').toggleClass('hidden', !Widget.templateData.slides.error);
$editor.siblings('input[name="data"]').val(aceEditor.getValue());
} else {
$editor.siblings('input[name="less"]').val(aceEditor.getValue());
}
});
aceEditor.on('blur', function(e, o) {
$(e.target).closest('.widget-area.ui-sortable').sortable( "enable" );
});
aceEditor.on('focus', function(e, o) {
$(e.target).closest('.widget-area.ui-sortable').sortable( "disable" );
});
$widget.find('script').nextAll().appendTo($settings);
$settings.find('[name="container"]').addClass('hidden');
$settings.find('[name="container"]').prev('label').addClass('hidden');
$widget.find('[name="sliderMode"]').on('change', function(e) {
var $input = $settings.find('.for-slider');
$input.find('input').prop('disabled', !Boolean(e.target.value));
$input.find('label').toggleClass('disabled', !Boolean(e.target.value));
$input.find('label').toggleClass('disabled', !Boolean(e.target.value));
$panel.find('.nav a[data-mode="json"]').parent('li').toggleClass('hidden', !Boolean(e.target.value));
}).trigger('change');
// Apply Selectize plugin to input elements
selectizeElements($settings);
// Adds Ace completer rules
langTools.addCompleter({
getCompletions: getCompletions
});
// Adds Ace commands
aceEditor.commands.on('afterExec', function(e) {
var editor = e.editor;
var hasCompleter = editor.completer && editor.completer.activated;
if (e.command.name === "insertstring") {
// Only autocomplete if there's a prefix that can be matched
if (/[\w{\.]/.test(e.args)) {
editor.execCommand("startAutocomplete");
}
} else if (e.command.name === "backspace") {
var range = e.editor.getSelectionRange();
var line = e.editor.session.getLine(range.end.row).substring(0, range.end.column);
if (/[\w{\.]+[\.\w]$/.test(line)) { //lastChar!=' ') {
editor.execCommand("startAutocomplete");
}
}
});
$panel.find('.nav a').off('click').on('click', function(e) {
$(this).closest('ul').find('li').removeClass('active');
$(this).closest('li').addClass('active');
if (this.dataset.mode=='settings') {
$editor.addClass('hidden');
$settings.removeClass('hidden');
} else {
$editor.removeClass('hidden');
$settings.addClass('hidden');
const ID = $(this).closest('ul').next().children('pre.ace-placeholder').attr('id');
Widget.instances[ID].editor.setSession(Widget.instances[ID].sessions[this.dataset.mode]);
aceEditor.focus();
}
}).first().click();
$sortable.sortable( "disable" );
/**
* This is a workaround for #160 until ace.js provide a way to define the parentNode of the autocomplete list.
* https://github.com/thm-mni-ii/JooMDD/pull/161
*/
// Select the node that will be observed for mutations
var htmlCollection = document.getElementsByTagName("body");
var body = htmlCollection.item(0);
// Options for the observer (which mutations to observe)
var config = { attributes: false, childList: true, subtree: false };
// Callback function to execute when mutations are observed
var callback = function(mutationsList, observer) {
for(var mutation of mutationsList) {
if (mutation.type == 'childList' && mutation.addedNodes.length > 0) {
var autocompleteDiv = mutation.addedNodes.item(0);
if (autocompleteDiv.classList.contains("ace_autocomplete")){
aceEditor.container.appendChild(aceEditor.completer.popup.container);
}
}
}
};
// Create an observer instance linked to the callback function
var observer = new MutationObserver(callback);
// Start observing the target node for configured mutations
observer.observe(body, config);
/** **/
} else {
$panel.find('.nav a').off('click');
var ID = $panel.find('pre.ace-placeholder').attr('id')
Widget.instances[ID].editor.destroy();
delete Widget.instances[ID];
$sortable.sortable( "enable" );
}
})
.on('click', 'div[data-widget="html-extended"] .delete-widget', function (evt) {
// Deletes widget an removes Ace instances
var $widget = $(this).closest('[data-widget]')
if ($widget.find('.panel-body').hasClass('hidden')) {
return;
} else {
$widget.find('.panel-body').addClass('hidden');
var ID = $(this).closest('[data-widget]').find('pre.ace-placeholder').attr('id');
Widget.instances[ID].editor.destroy();
delete Widget.instances[ID];
}
})
.on('change', 'div[data-widget="html-extended"] input[name="title"]', function (evt) {
// Updates widget title "on the fly"
var $title = $(evt.target).parents('[data-widget]').find('.panel-heading strong');
var title = ajaxify.data.availableWidgets.filter(function(el) {
return el.widget=='html-extended'
}).map(function(el) {
return el.name
}).join();
$title.text(title + ' - ' + this.value);
})
.on('sortstop', function(evt, ui ) {
// Hides widget panel an destroy Ace instances on sorting widgets
if (ui.item.data('widget')=='html-extended') {
ui.item.find('.panel-body').addClass('hidden');
var ID = ui.item.find('pre.ace-placeholder').attr('id');
if (Widget.instances[ID]) {
Widget.instances[ID].editor.destroy();
delete Widget.instances[ID];
}
}
});
};
function loadTemplateData(route, key, callback) {
if (typeof key==='function') {
callback = key;
key = '1';
}
key = key || '1';
// Retreives data from API route and add as a property in ajaxify.data object
loadData(route, function(err, templateData) {
if (err) { // If an error is returned, try with a user supplied parameter or a default value.
loadData(route + '/' + key, function(err, templateData) {
return callback(null, templateData || {});
})
} else {
return callback(null, templateData);
}
});
}
function loadData (route, callback) {
var apiXHR = $.ajax({
url: config.relative_path + '/api/' + route,
cache: false,
headers: {
'X-Return-To': app.previousUrl,
},
success: function (data, textStatus, xhr) {
if (!data) {
return;
}
if (xhr.getResponseHeader('X-Redirect')) {
return callback({
data: {
status: 302,
responseJSON: data,
},
textStatus: 'error',
});
}
data.config = config;
return callback(null, data);
},
error: function (data, textStatus) {
if (data.status === 0 && textStatus === 'error') {
data.status = 500;
data.responseJSON = data.responseJSON || {};
data.responseJSON.error = '[[error:no-connection]]';
}
return callback({
data: data,
textStatus: textStatus,
});
},
});
};
/**
* Note: The expected data, if any, can be one of:
* - an array of objects
* - an object with one the following properties: topics, categories or groups. Which value must be an array of values.
*
* @param {object} data - { uid: ..., area: ..., templateData: ..., data: ..., req: ..., res: ... }
* @return {object} The validated data
*/
function validateJsonData(data) {
let json;
try {
json = JSON.parse(data || '[]');
} catch (error) {
json = {error: error};
} finally {
if (Array.isArray(json)) { // Checks for Array
return json.filter(function(el) { // Filter only valid elements
return (el && typeof el==='object');
});
} else {
if (json.error) {
return json;
} else if (json.topics && Array.isArray(json.topics)) {
return {topics: filterArray(json.topics, 'number')};
} else if (json.categories && Array.isArray(json.categories)) {
return {categories: filterArray(json.categories, 'number')};
} else if (json.groups && Array.isArray(json.groups)) {
return {groups: filterArray(json.groups, 'string')};
} else {
return {error : 'Must contain an array of objects or one of the following objects: { "topics": [ .. , .. ] } or { "categories": [ .. , .. ] } or { "groups": [ .. , .. ] }'}; //[]
}
};
}
function filterArray(obj, type) {
return obj.filter(function(el) {
if (el && type==='number') {
return !isNaN(parseInt(el));
} else if (el && type==='string') {
return (typeof el===type);
} else {
return false;
}
});
}
}
function selectizeElements($obj) {
var options = '';
ajaxify.data.categories.forEach(function (el) {
options += '<option value=' + el.cid + ' data-color="'+el.color+'" data-bgColor="'+el.bgColor+'">' + el.name + '</option>';
});
$obj.find('select[name="cid"]:not(.selectized)').append(options);
// Selectize standard dropdown controls
$obj.find('select:not(.selectized)').selectize({
plugins: ['remove_button'],
delimiter: ',',
persist: false
});
}
function getCompletions(editor, session, pos, prefix, callback) {
if (session.getMode().$id!='ace/mode/html') {
return callback(null, []);
};
var line = session.getLine(pos.row);
if (line.substr(-1)==' ') return callback(null, []);
var buildDict = function(source, dict) {
dict = dict || {};
source = source || {};
Object.keys(source).forEach(function(el) {
var type = typeof source[el];
dict[el] = Array.isArray(source[el]) ? 'array' : type;
});
return dict;
}
var source = {...Widget.templateData};
var dict = buildDict(source);
var properties = line.match(/[\w+\.]+/g);
if (properties && properties.length) {
properties = properties.pop().split('.'); // will have a trailing '.'
properties.some(function(property) {
if (!property || !source) return; // empty string
var type = typeof source[property];
//dict[property] = type; //if (type) add property
if (type!='object') return true;
type = Array.isArray(source[property]) ? 'array' : type;
if (source[property]) {
if (Array.isArray(source[property])) {
source = source[property][0];
buildDict(source, dict);
} else {
source = source[property];
dict[property] = typeof source[property];
}
}
});
}
let wordList = [];
if (source) {
wordList = Object.keys(source).map(function(word) {
return {
caption: word,
value: word + ((dict[word]=='object' || dict[word]=='array') ? '' : '}'),
score: 1000,
meta: 'property ' + dict[word]
};
})
}
callback(null, wordList);
}
return Widget;
});