nodebb-plugin-composer-quill
Version:
Quill Composer for NodeBB
423 lines (365 loc) • 13.5 kB
JavaScript
;
/* globals document, $, window, define, socket, app, ajaxify, utils, config */
window.quill = {
uploads: {},
};
define('quill-nbb', [
'quill',
'composer/resize',
'components',
], function (Quill, resize, components) {
$(window).on('action:composer.loaded', function (ev, data) {
var postContainer = $('.composer[data-uuid="' + data.post_uuid + '"]');
var targetEl = postContainer.find('.write-container div');
window.quill.init(targetEl, data);
var cidEl = postContainer.find('.category-list');
if (cidEl.length) {
cidEl.attr('id', 'cmp-cid-' + data.post_uuid);
} else {
postContainer.append('<input id="cmp-cid-' + data.post_uuid + '" type="hidden" value="' + ajaxify.data.cid + '"/>');
}
// if (config.allowTopicsThumbnail && data.composerData.isMain) {
// var thumbToggleBtnEl = postContainer.find('.re-topic_thumb');
// var url = data.composerData.topic_thumb || '';
// postContainer.find('input#topic-thumb-url').val(url);
// postContainer.find('img.topic-thumb-preview').attr('src', url);
// if (url) {
// postContainer.find('.topic-thumb-clear-btn').removeClass('hide');
// }
// thumbToggleBtnEl.addClass('show');
// thumbToggleBtnEl.off('click').on('click', function() {
// var container = postContainer.find('.topic-thumb-container');
// container.toggleClass('hide', !container.hasClass('hide'));
// });
// }
resize.reposition(postContainer);
});
$(window).on('action:composer.check', function (ev, data) {
// Update bodyLen for length checking purposes
var quill = components.get('composer').filter('[data-uuid="' + data.post_uuid + '"]').find('.ql-container').data('quill');
data.bodyLen = quill.getLength() - 1;
});
$(window).on('action:chat.sent', function (evt, data) {
// Empty chat input
var quill = $('.chat-modal[data-roomid="' + data.roomId + '"] .ql-container, .expanded-chat[data-roomid="' + data.roomId + '"] .ql-container').data('quill');
quill.deleteText(0, quill.getLength());
// Reset text direction
var textDirection = $('html').attr('data-dir');
quill.format('direction', textDirection);
quill.format('align', textDirection === 'rtl' ? 'right' : 'left');
});
$(window).on('action:chat.prepEdit', function (evt, data) {
let value = data.inputEl.val();
const quill = data.inputEl.siblings('.ql-container').data('quill');
try {
value = JSON.parse(value);
quill.setContents(value, 'user');
} catch (e) {
app.alertError('[[error:invalid-json]]');
}
});
$(window).on('action:composer.uploadUpdate', function (evt, data) {
var filename = data.filename.replace(/^\d+_\d+_/, '');
var alertId = utils.slugify([data.post_uuid, filename].join('-'));
if (!window.quill.uploads[filename]) {
console.warn('[quill/uploads] Unable to find file (' + filename + ').');
app.removeAlert(alertId);
return;
}
if (!data.text.startsWith('/')) {
app.alert({
alert_id: alertId,
title: data.filename.replace(/\d_\d+_/, ''),
message: data.text,
timeout: 1000,
});
}
});
$(window).on('action:composer.upload', function (evt, data) {
var quill = components.get('composer').filter('[data-uuid="' + data.post_uuid + '"]').find('.ql-container').data('quill');
data.files.forEach((file) => {
const alertId = utils.slugify([data.post_uuid, file.filename].join('-'));
app.removeAlert(alertId);
// Image vs. file upload
if (file.isImage) {
quill.insertEmbed(quill.getSelection().index, 'image', file.url);
} else {
var selection = quill.getSelection();
if (selection.length) {
var linkText = quill.getText(selection.index, selection.length);
quill.deleteText(selection.index, selection.length);
quill.insertText(selection.index, linkText, {
link: file.url,
});
} else {
quill.insertText(selection.index, file.filename, {
link: file.url,
});
}
}
});
});
$(window).on('action:composer.uploadError', function (evt, data) {
var quill = components.get('composer').filter('[data-uuid="' + data.post_uuid + '"]').find('.ql-container').data('quill');
var textareaEl = components.get('composer').filter('[data-uuid="' + data.post_uuid + '"]').find('textarea');
textareaEl.val(!window.quill.isEmpty(quill) ? JSON.stringify(quill.getContents()) : '');
textareaEl.trigger('change');
textareaEl.trigger('keyup');
});
$(window).on('action:composer.uploadStart', function (evt, data) {
data.files.forEach(function (file) {
app.alert({
alert_id: utils.slugify([data.post_uuid, file.filename].join('-')),
title: file.filename.replace(/\d_\d+_/, ''),
message: data.text,
});
});
});
$(window).on('action:composer.insertIntoTextarea', function (evt, data) {
const quill = $(data.textarea).siblings('.ql-container').data('quill');
var selection = quill.getSelection(true);
quill.insertText(selection.index, data.value);
data.preventDefault = true;
// hack to convert emoji's inserted text into... an emoji
require(['quill-emoji'], function (Emoji) {
Emoji.convert.call(quill);
});
});
$(window).on('action:composer.updateTextareaSelection', function (evt, data) {
const quill = $(data.textarea).siblings('.ql-container').data('quill');
quill.setSelection(data.start, data.end - data.start);
data.preventDefault = true;
});
$(window).on('action:composer.wrapSelectionInTextareaWith', function (evt, data) {
const Delta = Quill.import('delta');
const quill = $(data.textarea).siblings('.ql-container').data('quill');
var range = quill.getSelection();
var insertionDelta;
if (range.length) {
insertionDelta = quill.getContents(range.index, range.length);
} else {
insertionDelta = new Delta();
}
// Wrap selection in spoiler tags
quill.updateContents(new Delta()
.retain(range.index)
.delete(range.length)
.insert(data.leading)
.concat(insertionDelta)
.insert(data.trailing)
);
if (range.length) {
// Update selection
quill.setSelection(range.index + (data.leading.length), range.length);
}
data.preventDefault = true;
});
$(window).on('action:chat.updateRemainingLength', function (evt, data) {
var quill = data.parent.find('.ql-container').data('quill');
var length = quill.getText().length;
data.parent.find('[component="chat/message/length"]').text(length);
data.parent.find('[component="chat/message/remaining"]').text(config.maximumChatMessageLength - length);
});
});
// Window events that must be attached immediately
$(window).on('action:chat.loaded', function (evt, containerEl) {
require([
'composer',
'composer/autocomplete',
'components',
], function (composer, autocomplete, components) {
// Create div element for composer
var targetEl = $('<div></div>').insertBefore(components.get('chat/input'));
var onInit = function () {
autocomplete.init($(containerEl));
};
// Load formatting options into DOM on-demand
if (composer.formatting) {
window.quill.init(targetEl, {
formatting: composer.formatting,
theme: 'bubble',
bounds: containerEl,
}, onInit);
} else {
socket.emit('plugins.composer.getFormattingOptions', function (err, options) {
if (err) {
app.alertError(err.message);
}
composer.formatting = options;
window.quill.init(targetEl, {
formatting: composer.formatting,
theme: 'bubble',
bounds: containerEl,
}, onInit);
});
}
});
});
// Internal methods
window.quill.init = function (targetEl, data, callback) {
require([
'quill', 'quill-magic-url', 'quill-emoji',
'composer/autocomplete', 'composer/formatting', 'composer/drafts',
], function (Quill, MagicUrl, Emoji, autocomplete, formatting, drafts) {
var textDirection = $('html').attr('data-dir');
var textareaEl = targetEl.siblings('textarea');
var toolbarOptions = {
container: [
[{ header: [1, 2, 3, 4, 5, 6, false] }], // h1..h6
[{ font: [] }],
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['link', 'blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }], // superscript/subscript
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ align: [] }],
['clean'],
],
handlers: {},
};
// Configure toolbar
var toolbarHandlers = formatting.getDispatchTable();
var group = [];
data.formatting.forEach(function (option) {
group.push(option.name);
toolbarOptions.handlers[option.name] = function () {
// Chicken-wrapper to pass additional values to handlers (to match composer-default behaviour)
var quill = targetEl.data('quill');
var selection = quill.getSelection(true);
toolbarHandlers[option.name].apply(quill, [textareaEl, selection.index, selection.index + selection.length]);
};
});
// -- upload privileges
['upload:post:file', 'upload:post:image'].forEach(function (privilege) {
if (app.user.privileges[privilege]) {
var name = privilege === 'upload:post:image' ? 'picture' : 'upload';
group.unshift(name);
toolbarOptions.handlers[name] = toolbarHandlers[name].bind($('.formatting-bar'));
}
});
toolbarOptions.container.push(group);
// Quill...
Quill.register('modules/magicUrl', MagicUrl.default);
var quill = new Quill(targetEl.get(0), {
theme: data.theme || 'snow',
modules: {
toolbar: toolbarOptions,
magicUrl: {
normalizeUrlOptions: {
sortQueryParameters: false,
defaultProtocol: 'https:',
},
},
},
bounds: data.bounds || document.body,
});
targetEl.data('quill', quill);
targetEl.find('.ql-editor').addClass('write');
// Configure toolbar icons (must be done after quill itself is instantiated)
var toolbarEl = targetEl.siblings('.ql-toolbar').length ? targetEl.siblings('.ql-toolbar') : targetEl.find('.ql-toolbar');
data.formatting.forEach(function (option) {
var buttonEl = toolbarEl.find('.ql-' + option.name);
buttonEl.html('<i class="' + option.className + '"></i>');
if (option.mobile) {
buttonEl.addClass('visible-xs');
}
});
['upload:post:image', 'upload:post:file'].forEach(function (privilege) {
if (app.user.privileges[privilege]) {
var className = privilege === 'upload:post:image' ? 'picture' : 'upload';
var buttonEl = toolbarEl.find('.ql-' + className);
if (className === 'picture') {
buttonEl.html('<i class="fa fa-file-image-o"></i>');
} else {
buttonEl.html('<span class="fa-stack"><i class="fa fa-file-o fa-stack-1x"></i><i class="fa fa-arrow-up fa-stack-1x"></i></span>');
}
}
});
$(window).trigger('action:quill.load', quill);
// Restore text if contained in composerData or drafts
const draft = data.composerData && drafts.get(data.composerData.save_id);
if (data.composerData && data.composerData.body) {
try {
var unescaped = data.composerData.body.replace(/"/g, '"');
var delta = JSON.parse(unescaped);
delta.ops.push({
insert: '\n',
attributes: {
direction: textDirection,
align: textDirection === 'rtl' ? 'right' : 'left',
},
});
quill.setContents(delta, 'api');
} catch (e) {
quill.setContents({ ops: [{
insert: data.composerData.body.toString(),
attributes: {
direction: textDirection,
align: textDirection === 'rtl' ? 'right' : 'left',
},
}] }, 'api');
}
// Move cursor to the very end
var length = quill.getLength();
quill.setSelection(length);
} else if (draft && draft.text) {
// Set title
targetEl.parents('.composer').find('.title').val(draft.title);
const delta = JSON.parse(draft.text);
delta.ops.push({
insert: '\n',
attributes: {
direction: textDirection,
align: textDirection === 'rtl' ? 'right' : 'left',
},
});
quill.setContents(delta, 'api');
}
// Automatic RTL support
quill.format('direction', textDirection);
quill.format('align', textDirection === 'rtl' ? 'right' : 'left');
autocomplete.init(targetEl, data.post_uuid);
Emoji.enable(quill);
// Update textarea on editor-change event. This allows compatibility with
// how NodeBB handles things like drafts, etc.
quill.on('editor-change', function () {
textareaEl.val(JSON.stringify(quill.getContents()));
textareaEl.trigger('change');
textareaEl.trigger('keyup');
});
// Special handling on text-change
quill.on('text-change', function () {
if (window.quill.isEmpty(quill)) {
quill.deleteText(0, quill.getLength());
textareaEl.val('');
}
});
// Handle tab/enter for autocomplete
var doAutocomplete = function () {
setTimeout(Emoji.convert.bind(quill), 0);
return !$('.composer-autocomplete-dropdown-' + data.post_uuid + ':visible').length;
};
[9, 13].forEach(function (keyCode) {
quill.keyboard.addBinding({
key: keyCode,
}, doAutocomplete);
quill.keyboard.bindings[keyCode].unshift(quill.keyboard.bindings[keyCode].pop());
});
if (!data.composerData || data.composerData.action !== 'topics.post') {
// Oddly, a 0ms timeout is required here otherwise .focus() does not work
setTimeout(quill.focus.bind(quill), 0);
}
if (typeof callback === 'function') {
callback();
}
});
return window.quill;
};
window.quill.isEmpty = function (quill) {
const contents = quill.getContents();
if (contents.ops.length === 1) {
const value = contents.ops[0].insert.replace(/[\s\n]/g, '');
return value === '';
}
return false;
};