wekan/client/components/main/editor.js

413 lines
15 KiB
JavaScript
Raw Normal View History

2020-09-16 14:52:00 -05:00
import Attachments from '/models/attachments';
const specialHandles = [
{userId: 'board_members', username: 'board_members'},
{userId: 'card_members', username: 'card_members'}
];
const specialHandleNames = specialHandles.map(m => m.username);
2021-11-19 00:29:56 +01:00
BlazeComponent.extendComponent({
onRendered() {
const textareaSelector = 'textarea';
const mentions = [
// User mentions
{
match: /\B@([\w.-]*)$/,
2021-11-19 00:29:56 +01:00
search(term, callback) {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
callback(
_.union(
currentBoard
.activeMembers()
.map(member => {
const user = Users.findOne(member.userId);
const username = user.username;
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname : "";
return username.includes(term) || fullName.includes(term) ? user : null;
2021-11-19 00:29:56 +01:00
})
.filter(Boolean), [...specialHandles])
2021-11-19 00:29:56 +01:00
);
},
template(user) {
if (user.profile && user.profile.fullname) {
return (user.profile.fullname + " (" + user.username + ")");
}
return user.username;
2021-11-19 00:29:56 +01:00
},
replace(user) {
if (user.profile && user.profile.fullname) {
return `@${user.username} (${user.profile.fullname}) `;
}
return `@${user.username} `;
2021-11-19 00:29:56 +01:00
},
index: 1,
Renaissance _,,ad8888888888bba,_ ,ad88888I888888888888888ba, ,88888888I88888888888888888888a, ,d888888888I8888888888888888888888b, d88888PP"""" ""YY88888888888888888888b, ,d88"'__,,--------,,,,.;ZZZY8888888888888, ,8IIl'" ;;l"ZZZIII8888888888, ,I88l;' ;lZZZZZ888III8888888, ,II88Zl;. ;llZZZZZ888888I888888, ,II888Zl;. .;;;;;lllZZZ888888I8888b ,II8888Z;; `;;;;;''llZZ8888888I8888, II88888Z;' .;lZZZ8888888I888b II88888Z; _,aaa, .,aaaaa,__.l;llZZZ88888888I888 II88888IZZZZZZZZZ, .ZZZZZZZZZZZZZZ;llZZ88888888I888, II88888IZZ<'(@@>Z| |ZZZ<'(@@>ZZZZ;;llZZ888888888I88I ,II88888; `""" ;| |ZZ; `""" ;;llZ8888888888I888 II888888l `;; .;llZZ8888888888I888, ,II888888Z; ;;; .;;llZZZ8888888888I888I III888888Zl; .., `;; ,;;lllZZZ88888888888I888 II88888888Z;;...;(_ _) ,;;;llZZZZ88888888888I888, II88888888Zl;;;;;' `--'Z;. .,;;;;llZZZZ88888888888I888b ]I888888888Z;;;;' ";llllll;..;;;lllZZZZ88888888888I8888, II888888888Zl.;;"Y88bd888P";;,..;lllZZZZZ88888888888I8888I II8888888888Zl;.; `"PPP";;;,..;lllZZZZZZZ88888888888I88888 II888888888888Zl;;. `;;;l;;;;lllZZZZZZZZW88888888888I88888 `II8888888888888Zl;. ,;;lllZZZZZZZZWMZ88888888888I88888 II8888888888888888ZbaalllZZZZZZZZZWWMZZZ8888888888I888888, `II88888888888888888b"WWZZZZZWWWMMZZZZZZI888888888I888888b `II88888888888888888;ZZMMMMMMZZZZZZZZllI888888888I8888888 `II8888888888888888 `;lZZZZZZZZZZZlllll888888888I8888888, II8888888888888888, `;lllZZZZllllll;;.Y88888888I8888888b, ,II8888888888888888b .;;lllllll;;;.;..88888888I88888888b, II888888888888888PZI;. .`;;;.;;;..; ...88888888I8888888888, II888888888888PZ;;';;. ;. .;. .;. .. Y8888888I88888888888b, ,II888888888PZ;;' `8888888I8888888888888b, II888888888' 888888I8888888888888888 ,II888888888 ,888888I8888888888888888 ,d88888888888 d888888I8888888888ZZZZZZ ,ad888888888888I 8888888I8888ZZZZZZZZZZZZ 888888888888888' 888888IZZZZZZZZZZZZZZZZZ 8888888888P'8P' Y888ZZZZZZZZZZZZZZZZZZZZ 888888888, " ,ZZZZZZZZZZZZZZZZZZZZZZZ 8888888888, ,ZZZZZZZZZZZZZZZZZZZZZZZZZZ 888888888888a, _ ,ZZZZZZZZZZZZZZZZZZZZ88888888 888888888888888ba,_d' ,ZZZZZZZZZZZZZZZZZ8888888888888 8888888888888888888888bbbaaa,,,______,ZZZZZZZZZZZZZZZ88888888888888888 88888888888888888888888888888888888ZZZZZZZZZZZZZZZ88888888888888888888 8888888888888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888 888888888888888888888888888888888ZZZZZZZZZZZZZZ88888888888888888888888 8888888888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888888 88888888888888888888888888888ZZZZZZZZZZZZZZ888888888888888888888888888 8888888888888888888888888888ZZZZZZZZZZZZZZ88888888888888888 Normand 8 88888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888 Veilleux 8 8888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888888888888
2015-05-12 19:20:58 +02:00
},
2021-11-19 00:29:56 +01:00
];
const enableTextarea = function() {
const $textarea = this.$(textareaSelector);
autosize($textarea);
$textarea.escapeableTextComplete(mentions);
};
2021-11-19 00:29:56 +01:00
if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) {
const isSmall = Utils.isMiniScreen();
const toolbar = isSmall
? [
['view', ['fullscreen']],
['table', ['table']],
['font', ['bold', 'underline']],
//['fontsize', ['fontsize']],
['color', ['color']],
]
: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['fontsize', ['fontsize']],
['fontname', ['fontname']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
//['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled
['insert', ['link']], //, 'picture']], // modal popup has issue somehow :(
['view', ['fullscreen', 'codeview', 'help']],
];
const cleanPastedHTML = function(input) {
const badTags = [
'style',
'script',
'applet',
'embed',
'noframes',
'noscript',
'meta',
'link',
'button',
'form',
].join('|');
const badPatterns = new RegExp(
`(?:${[
`<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`,
`<(${badTags})[^>]*?\\/>`,
].join('|')})`,
'gi',
);
let output = input;
// remove bad Tags
output = output.replace(badPatterns, '');
// remove attributes ' style="..."'
const badAttributes = new RegExp(
`(?:${[
'on\\S+=([\'"]?).*?\\1',
'href=([\'"]?)javascript:.*?\\2',
'style=([\'"]?).*?\\3',
'target=\\S+',
].join('|')})`,
'gi',
);
output = output.replace(badAttributes, '');
output = output.replace(/(<a )/gi, '$1target=_ '); // always to new target
return output;
};
2021-11-19 00:29:56 +01:00
const editor = '.editor';
const selectors = [
`.js-new-description-form ${editor}`,
`.js-new-comment-form ${editor}`,
`.js-edit-comment ${editor}`,
].join(','); // only new comment and edit comment
const inputs = $(selectors);
if (inputs.length === 0) {
// only enable richereditor to new comment or edit comment no others
enableTextarea();
} else {
const placeholder = inputs.attr('placeholder') || '';
const mSummernotes = [];
const getSummernote = function(input) {
const idx = inputs.index(input);
if (idx > -1) {
return mSummernotes[idx];
}
return undefined;
};
inputs.each(function(idx, input) {
mSummernotes[idx] = $(input).summernote({
placeholder,
callbacks: {
onInit(object) {
const originalInput = this;
$(originalInput).on('submitted', function() {
// when comment is submitted, the original textarea will be set to '', so shall we
if (!this.value) {
const sn = getSummernote(this);
sn && sn.summernote('code', '');
}
});
2021-11-19 00:29:56 +01:00
const jEditor = object && object.editable;
const toolbar = object && object.toolbar;
if (jEditor !== undefined) {
jEditor.escapeableTextComplete(mentions);
}
if (toolbar !== undefined) {
const fBtn = toolbar.find('.btn-fullscreen');
fBtn.on('click', function() {
const $this = $(this),
isActive = $this.hasClass('active');
$('.minicards,#header-quick-access').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually
});
}
},
onImageUpload(files) {
const $summernote = getSummernote(this);
if (files && files.length > 0) {
const image = files[0];
const currentCard = Utils.getCurrentCard();
const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
2020-09-13 22:17:58 -05:00
const processUpload = function(file) {
const uploader = Attachments.insert(
{
file,
meta: Utils.getCommonAttachmentMetaFrom(card),
2020-09-13 22:17:58 -05:00
chunkSize: 'dynamic',
2021-11-19 00:29:56 +01:00
},
2020-09-13 22:17:58 -05:00
false,
2021-11-19 00:29:56 +01:00
);
uploader.on('uploaded', (error, fileRef) => {
2020-09-13 22:17:58 -05:00
if (!error) {
if (fileRef.isImage) {
const img = document.createElement('img');
img.src = fileRef.link();
img.setAttribute('width', '100%');
$summernote.summernote('insertNode', img);
2020-09-13 22:17:58 -05:00
}
}
});
uploader.start();
2021-11-19 00:29:56 +01:00
};
if (MAX_IMAGE_PIXEL) {
const reader = new FileReader();
reader.onload = function(e) {
const dataurl = e && e.target && e.target.result;
if (dataurl !== undefined) {
// need to shrink image
Utils.shrinkImage({
dataurl,
maxSize: MAX_IMAGE_PIXEL,
ratio: COMPRESS_RATIO,
toBlob: true,
callback(blob) {
if (blob !== false) {
blob.name = image.name;
2020-09-13 22:17:58 -05:00
processUpload(blob);
}
2021-11-19 00:29:56 +01:00
},
});
}
2021-11-19 00:29:56 +01:00
};
reader.readAsDataURL(image);
} else {
2020-09-13 22:17:58 -05:00
processUpload(image);
2021-11-19 00:29:56 +01:00
}
}
2021-11-19 00:29:56 +01:00
},
onPaste(e) {
var clipboardData = e.clipboardData;
var pastedData = clipboardData.getData('Text');
2021-11-19 00:29:56 +01:00
//if pasted data is an image, exit
if (!pastedData.length) {
e.preventDefault();
return;
}
2021-11-19 00:29:56 +01:00
// clear up unwanted tag info when user pasted in text
const thisNote = this;
const updatePastedText = function(object) {
const someNote = getSummernote(object);
// Fix Pasting text into a card is adding a line before and after
// (and multiplies by pasting more) by changing paste "p" to "br".
// Fixes https://github.com/wekan/wekan/2890 .
// == Fix Start ==
someNote.execCommand('defaultParagraphSeparator', false, 'br');
// == Fix End ==
const original = someNote.summernote('code');
const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML.
someNote.summernote('code', ''); //clear original
someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code.
};
setTimeout(function() {
//this kinda sucks, but if you don't do a setTimeout,
//the function is called before the text is really pasted.
updatePastedText(thisNote);
}, 10);
},
},
dialogsInBody: true,
spellCheck: true,
disableGrammar: false,
disableDragAndDrop: false,
toolbar,
popover: {
image: [
['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']],
['float', ['floatLeft', 'floatRight', 'floatNone']],
['remove', ['removeMedia']],
],
link: [['link', ['linkDialogShow', 'unlink']]],
table: [
['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
],
air: [
['color', ['color']],
['font', ['bold', 'underline', 'clear']],
],
},
2021-11-19 00:29:56 +01:00
height: 200,
});
});
2021-11-19 00:29:56 +01:00
}
} else {
enableTextarea();
}
2021-11-19 00:29:56 +01:00
},
events() {
return [
{
'click a.fa.fa-copy'(event) {
2021-11-19 00:29:56 +01:00
const $editor = this.$('textarea.editor');
const promise = Utils.copyTextToClipboard($editor[0].value);
const $tooltip = this.$('.copied-tooltip');
Utils.showCopied(promise, $tooltip);
2021-11-19 00:29:56 +01:00
},
}
]
}
2021-11-19 00:29:56 +01:00
}).register('editor');
import DOMPurify from 'dompurify';
// Additional safeAttrValue function to allow for other specific protocols
// See https://github.com/leizongmin/js-xss/issues/52#issuecomment-241354114
/*
function mySafeAttrValue(tag, name, value, cssFilter) {
// only when the tag is 'a' and attribute is 'href'
// then use your custom function
if (tag === 'a' && name === 'href') {
// only filter the value if starts with 'cbthunderlink:' or 'aodroplink'
2020-11-29 04:19:28 +02:00
if (
/^thunderlink:/gi.test(value) ||
/^cbthunderlink:/gi.test(value) ||
/^aodroplink:/gi.test(value) ||
/^onenote:/gi.test(value) ||
/^file:/gi.test(value) ||
/^abasurl:/gi.test(value) ||
/^conisio:/gi.test(value) ||
/^mailspring:/gi.test(value)
2020-11-29 04:19:28 +02:00
) {
return value;
2020-11-29 04:19:28 +02:00
} else {
2020-11-10 22:01:04 -03:00
// use the default safeAttrValue function to process all non cbthunderlinks
return sanitizeXss.safeAttrValue(tag, name, value, cssFilter);
}
} else {
// use the default safeAttrValue function to process it
return sanitizeXss.safeAttrValue(tag, name, value, cssFilter);
}
2020-11-29 04:19:28 +02:00
}
*/
// XXX I believe we should compute a HTML rendered field on the server that
// would handle markdown and user mentions. We can simply have two
// fields, one source, and one compiled version (in HTML) and send only the
// compiled version to most users -- who don't need to edit.
// In the meantime, all the transformation are done on the client using the
// Blaze API.
2019-06-28 12:52:09 -05:00
const at = HTML.CharRef({ html: '&commat;', str: '@' });
Blaze.Template.registerHelper(
'mentions',
new Template('mentions', function() {
const view = this;
let content = Blaze.toHTML(view.templateContentBlock);
const currentBoard = Boards.findOne(Session.get('currentBoard'));
2020-11-29 04:19:28 +02:00
if (!currentBoard)
return HTML.Raw(
DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),
);
const knowedUsers = _.union(currentBoard.members.map(member => {
2019-06-28 12:52:09 -05:00
const u = Users.findOne(member.userId);
if (u) {
member.username = u.username;
}
return member;
}), [...specialHandles]);
const mentionRegex = /\B@([\w.-]*)/gi;
2019-06-28 12:52:09 -05:00
let currentMention;
while ((currentMention = mentionRegex.exec(content)) !== null) {
const [fullMention, quoteduser, simple] = currentMention;
const username = quoteduser || simple;
2019-06-28 12:52:09 -05:00
const knowedUser = _.findWhere(knowedUsers, { username });
if (!knowedUser) {
continue;
}
2019-06-28 12:52:09 -05:00
const linkValue = [' ', at, knowedUser.username];
let linkClass = 'atMention js-open-member';
if (knowedUser.userId === Meteor.userId()) {
linkClass += ' me';
}
// This @user mention link generation did open same Wekan
// window in new tab, so now A is changed to U so it's
// underlined and there is no link popup. This way also
// text can be selected more easily.
//const link = HTML.A(
const link = HTML.U(
2019-06-28 12:52:09 -05:00
{
class: linkClass,
// XXX Hack. Since we stringify this render function result below with
// `Blaze.toHTML` we can't rely on blaze data contexts to pass the
// `userId` to the popup as usual, and we need to store it in the DOM
// using a data attribute.
'data-userId': knowedUser.userId,
},
linkValue,
);
2019-06-28 12:52:09 -05:00
content = content.replace(fullMention, Blaze.toHTML(link));
}
return HTML.Raw(
DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),
);
2019-06-28 12:52:09 -05:00
}),
);
Template.viewer.events({
// Viewer sometimes have click-able wrapper around them (for instance to edit
// the corresponding text). Clicking a link shouldn't fire these actions, stop
// we stop these event at the viewer component level.
2019-06-28 12:52:09 -05:00
'click a'(event, templateInstance) {
2020-05-03 00:33:15 +02:00
const prevent = true;
2019-06-28 12:52:09 -05:00
const userId = event.currentTarget.dataset.userid;
if (userId) {
2019-06-28 12:52:09 -05:00
Popup.open('member').call({ userId }, event, templateInstance);
} else {
const href = event.currentTarget.href;
if (href) {
window.open(href, '_blank');
}
}
if (prevent) {
event.stopPropagation();
// XXX We hijack the build-in browser action because we currently don't have
// `_blank` attributes in viewer links, and the transformer function is
// handled by a third party package that we can't configure easily. Fix that
// by using directly `_blank` attribute in the rendered HTML.
event.preventDefault();
}
},
});