nodebb-plugin-composer-quill
Version:
Quill Composer for NodeBB
442 lines (383 loc) • 13.8 kB
JavaScript
;
/* globals document, $, window, define, socket, app, ajaxify, config */
window.quill = {
uploads: {},
};
define('quill-nbb', [
'quill',
'composer/resize',
'components',
'slugify',
], (Quill, resize, components, slugify) => {
$(window).on('action:composer.loaded', (ev, data) => {
const postContainer = $(`.composer[data-uuid="${data.post_uuid}"]`);
const targetEl = postContainer.find('.write-container div');
window.quill.init(targetEl, data);
const 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', (ev, data) => {
// Update bodyLen for length checking purposes
const 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', (evt, data) => {
// Empty chat input
const 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
const textDirection = $('html').attr('data-dir');
quill.format('direction', textDirection);
quill.format('align', textDirection === 'rtl' ? 'right' : 'left');
});
$(window).on('action:chat.prepEdit', (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', (evt, data) => {
const filename = data.filename.replace(/^\d+_\d+_/, '');
const alertId = generateAlertId(data.post_uuid, filename);
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', (evt, data) => {
const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill');
data.files.forEach((file) => {
const alertId = generateAlertId(data.post_uuid, file.filename);
app.removeAlert(alertId);
// Image vs. file upload
if (file.isImage) {
quill.insertEmbed(quill.getSelection().index, 'image', file.url);
} else {
const selection = quill.getSelection();
if (selection.length) {
const 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', (evt, data) => {
const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill');
const 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', (evt, data) => {
data.files.forEach((file) => {
app.alert({
alert_id: generateAlertId(data.post_uuid, file.filename),
title: file.filename.replace(/\d_\d+_/, ''),
message: data.text,
});
});
});
$(window).on('action:composer.insertIntoTextarea', (evt, data) => {
const quill = $(data.textarea).siblings('.ql-container').data('quill');
const 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'], (Emoji) => {
Emoji.convert.call(quill);
});
});
$(window).on('action:composer.updateTextareaSelection', (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', (evt, data) => {
const Delta = Quill.import('delta');
const quill = $(data.textarea).siblings('.ql-container').data('quill');
const range = quill.getSelection();
let 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', (evt, data) => {
const quill = data.parent.find('.ql-container').data('quill');
const length = quill.getText().length - 1;
data.parent.find('[component="chat/message/length"]').text(length);
data.parent.find('[component="chat/message/remaining"]').text(config.maximumChatMessageLength - length);
});
function generateAlertId(uuid, filename) {
return slugify([uuid, filename].join('-'));
}
});
// Window events that must be attached immediately
$(window).on('action:chat.loaded', (evt, containerEl) => {
require([
'composer',
'composer/autocomplete',
'components',
], (composer, autocomplete, components) => {
// Create div element for composer
const targetEl = $('<div></div>').insertBefore(components.get('chat/input'));
const 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', (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/drafts',
], (Quill, MagicUrl, Emoji, autocomplete, drafts) => {
const textDirection = $('html').attr('data-dir');
const textareaEl = targetEl.siblings('textarea');
window.quill.configureToolbar(targetEl, data).then(({ toolbar }) => {
// Quill...
Quill.register('modules/magicUrl', MagicUrl.default);
const quill = new Quill(targetEl.get(0), {
theme: data.theme || 'snow',
modules: {
toolbar,
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)
const toolbarEl = targetEl.siblings('.ql-toolbar').length ? targetEl.siblings('.ql-toolbar') : targetEl.find('.ql-toolbar');
data.formatting.forEach((option) => {
const 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((privilege) => {
if (app.user.privileges[privilege]) {
const className = privilege === 'upload:post:image' ? 'picture' : 'upload';
const buttonEl = toolbarEl.find(`.ql-${className}`);
if (className === 'picture') {
buttonEl.html('<i class="fa fa-file-image-o"></i>');
} else {
buttonEl.html('<i class="fa fa-file-o"></i>');
}
}
});
$(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 {
const unescaped = data.composerData.body.replace(/"/g, '"');
const 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
const 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', () => {
textareaEl.val(JSON.stringify(quill.getContents()));
textareaEl.trigger('change');
textareaEl.trigger('keyup');
});
// Special handling on text-change
quill.on('text-change', () => {
if (window.quill.isEmpty(quill)) {
quill.deleteText(0, quill.getLength());
textareaEl.val('');
}
});
// Handle tab/enter for autocomplete
const doAutocomplete = function () {
setTimeout(Emoji.convert.bind(quill), 0);
return !$(`.composer-autocomplete-dropdown-${data.post_uuid}:visible`).length;
};
[9, 13].forEach((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.configureToolbar = async (targetEl, data) => {
const textareaEl = targetEl.siblings('textarea');
const [formatting, hooks] = await new Promise((resolve) => {
require(['composer/formatting', 'hooks'], (...libs) => resolve(libs));
});
const toolbar = {
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
const toolbarHandlers = formatting.getDispatchTable();
const group = [];
data.formatting.forEach((option) => {
group.push(option.name);
toolbar.handlers[option.name] = function () {
// Chicken-wrapper to pass additional values to handlers (to match composer-default behaviour)
const quill = targetEl.data('quill');
const selection = quill.getSelection(true);
toolbarHandlers[option.name].apply(
data.postContainer,
[textareaEl.get(0), selection.index, selection.index + selection.length]
);
};
});
// -- upload privileges
['upload:post:file', 'upload:post:image'].forEach((privilege) => {
if (app.user.privileges[privilege]) {
const name = privilege === 'upload:post:image' ? 'picture' : 'upload';
group.unshift(name);
toolbar.handlers[name] = toolbarHandlers[name].bind($('.formatting-bar'));
}
});
toolbar.container.push(group);
// Allow plugins to modify toolbar
return await hooks.fire('filter:quill.toolbar', { toolbar });
};
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;
};