gitlab-org--gitlab-foss/app/assets/javascripts/gfm_auto_complete.js
Tim Zallmann d07919de90 Moved all emojis to the public folder
Created new emojis map in public folder

Renamed folder to emojis

Loading now the emojis from Localstorage or from Server

Moved all emojis to the public folder

Loading the emojis.json file now through AJAX

Loads now the map in the web element instead when building the emoji tag

Updated the custom Element Setup to modern method

Fixed Emoji Karma Specs for async loading

Loading now the emojis from Localstorage or from Server

Loads now the map in the web element instead when building the emoji tag

Fixed problem with FIXTURE_PATH for emojis fixtures

Fixes Linting Error in gemojione.rake

Fixed Emoji Karma Specs

Fix static type check in gemojione and check if already registered

Testing if the Emoji Support Check is failing Rspec

Change of CLass Name, returning true on check to test

Fixes failing Emoji RSpec Tests

Moved Emojis into public/-/emojis/1/

Fixed Linting Errors in gl_emoji

Fix to fixtures creation for emojis

Fixed path spec for new subdirectory -/emojis

Optimized emojis.json output

Fix for Emoji Spec failure due to unicode dataset

Better catch handling for emojis
2019-03-07 09:33:03 +00:00

630 lines
19 KiB
JavaScript

import $ from 'jquery';
import _ from 'underscore';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
}
export const defaultAutocompleteConfig = {
emojis: true,
members: true,
issues: true,
mergeRequests: true,
epics: true,
milestones: true,
labels: true,
snippets: true,
};
class GfmAutoComplete {
constructor(dataSources) {
this.dataSources = dataSources || {};
this.cachedData = {};
this.isLoadingData = {};
}
setup(input, enableMap = defaultAutocompleteConfig) {
// Add GFM auto-completion to all input fields, that accept GFM input.
this.input = input || $('.js-gfm-input');
this.enableMap = enableMap;
this.setupLifecycle();
}
setupLifecycle() {
this.input.each((i, input) => {
const $input = $(input);
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
$input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
// Needed for quick actions with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
$input.on('clear-commands-cache.atwho', () => this.clearCache());
});
}
setupAtWho($input) {
if (this.enableMap.emojis) this.setupEmoji($input);
if (this.enableMap.members) this.setupMembers($input);
if (this.enableMap.issues) this.setupIssues($input);
if (this.enableMap.milestones) this.setupMilestones($input);
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
// We don't instantiate the quick actions autocomplete for note and issue/MR edit forms
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
alias: 'commands',
searchKey: 'search',
skipSpecialCharacterTest: true,
skipMarkdownCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
const cssClasses = [];
if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
// eslint-disable-next-line no-template-curly-in-string
let tpl = '<li class="<%- className %>"><span class="name">/${name}</span>';
if (value.aliases.length > 0) {
tpl += ' <small class="aliases">(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
tpl += ' <small class="params"><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %> <%- warningText %></i></small>';
}
tpl += '</li>';
if (value.warning) {
cssClasses.push('has-warning');
}
return _.template(tpl)({
...value,
className: cssClasses.join(' '),
warningText: value.warning ? `(${value.warning})` : '',
});
},
insertTpl(value) {
// eslint-disable-next-line no-template-curly-in-string
let tpl = '/${name} ';
let referencePrefix = null;
if (value.params.length > 0) {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
tpl += '<%- referencePrefix %>';
}
}
return _.template(tpl)({ referencePrefix });
},
suffix: '',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(commands) {
if (GfmAutoComplete.isLoading(commands)) return commands;
return $.map(commands, c => {
let search = c.name;
if (c.aliases.length > 0) {
search = `${search} ${c.aliases.join(' ')}`;
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
warning: c.warning,
search,
};
});
},
matcher(flag, subtext) {
const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
const match = regexp.exec(subtext);
if (match) {
return match[1];
}
return null;
},
},
});
}
setupEmoji($input) {
// Emoji
$input.atwho({
at: ':',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value && value.name) {
tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: ':${name}:',
skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
...this.getDefaultCallbacks(),
matcher(flag, subtext) {
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(subtext);
return match && match.length ? match[1] : null;
},
},
});
}
setupMembers($input) {
// Team Members
$input.atwho({
at: '@',
alias: 'users',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
const { avatarTag, username, title } = value;
if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag,
username,
title,
});
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(members) {
return $.map(members, m => {
let title = '';
if (m.username == null) {
return m;
}
title = m.name;
if (m.count) {
title += ` (${m.count})`;
}
const GROUP_TYPE = 'Group';
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : '';
const imgAvatar = `<img src="${m.avatar_url}" alt="${
m.username
}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
return {
username: m.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title),
search: sanitize(`${m.username} ${m.name}`),
};
});
},
},
});
}
setupIssues($input) {
$input.atwho({
at: '#',
alias: 'issues',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
skipSpecialCharacterTest: true,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(issues) {
return $.map(issues, i => {
if (i.title == null) {
return i;
}
return {
id: i.iid,
title: sanitize(i.title),
reference: i.reference,
search: `${i.iid} ${i.title}`,
};
});
},
},
});
}
setupMilestones($input) {
$input.atwho({
at: '%',
alias: 'milestones',
searchKey: 'search',
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
tmpl = GfmAutoComplete.Milestones.templateFunction(value.title);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(milestones) {
return $.map(milestones, m => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
search: m.title,
};
});
},
},
});
}
setupMergeRequests($input) {
$input.atwho({
at: '!',
alias: 'mergerequests',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
skipSpecialCharacterTest: true,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(merges) {
return $.map(merges, m => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
reference: m.reference,
search: `${m.iid} ${m.title}`,
};
});
},
},
});
}
setupLabels($input) {
const fetchData = this.fetchData.bind(this);
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
let command = '';
$input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
let tmpl = GfmAutoComplete.Labels.templateFunction(value.color, value.title);
if (GfmAutoComplete.isLoading(value)) {
tmpl = GfmAutoComplete.Loading.template;
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(merges) {
if (GfmAutoComplete.isLoading(merges)) return merges;
return $.map(merges, m => ({
title: sanitize(m.title),
color: m.color,
search: m.title,
set: m.set,
}));
},
matcher(flag, subtext) {
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
const subtextNodes = subtext
.split(/\n+/g)
.pop()
.split(GfmAutoComplete.regexSubtext);
// Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands.
command = subtextNodes.find(node => {
if (
node === LABEL_COMMAND.LABEL ||
node === LABEL_COMMAND.RELABEL ||
node === LABEL_COMMAND.UNLABEL
) {
return node;
}
return null;
});
return match && match.length ? match[1] : null;
},
filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) {
fetchData(this.$inputor, this.at);
return data;
}
if (data === GfmAutoComplete.defaultLoadingData) {
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
}
// The `LABEL_COMMAND.RELABEL` is intentionally skipped
// because we want to return all the labels (unfiltered) for that command.
if (command === LABEL_COMMAND.LABEL) {
// Return labels with set: undefined.
return data.filter(label => !label.set);
} else if (command === LABEL_COMMAND.UNLABEL) {
// Return labels with set: true.
return data.filter(label => label.set);
}
return data;
},
},
});
}
setupSnippets($input) {
$input.atwho({
at: '$',
alias: 'snippets',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(snippets) {
return $.map(snippets, m => {
if (m.title == null) {
return m;
}
return {
id: m.id,
title: sanitize(m.title),
search: `${m.id} ${m.title}`,
};
});
},
},
});
}
getDefaultCallbacks() {
const fetchData = this.fetchData.bind(this);
return {
sorter(query, items, searchKey) {
this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
if (GfmAutoComplete.isLoading(items)) {
this.setting.highlightFirst = false;
return items;
}
return $.fn.atwho.default.callbacks.sorter(query, items, searchKey);
},
filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) {
fetchData(this.$inputor, this.at);
return data;
}
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
},
beforeInsert(value) {
let withoutAt = value.substring(1);
const at = value.charAt();
if (value && !this.setting.skipSpecialCharacterTest) {
const regex = at === '~' ? /\W|^\d+$/ : /\W/;
if (withoutAt && regex.test(withoutAt)) {
withoutAt = `"${withoutAt}"`;
}
}
// We can ignore this for quick actions because they are processed
// before Markdown.
if (!this.setting.skipMarkdownCharacterTest) {
withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&');
}
return `${at}${withoutAt}`;
},
matcher(flag, subtext) {
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
if (match) {
return match[1];
}
return null;
},
};
}
fetchData($input, at) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
import(/* webpackChunkName: 'emoji' */ './emoji')
.then(({ initEmojiMap, getValidEmojiNames, glEmojiTag }) => {
initEmojiMap()
.then(() => {
this.loadData($input, at, getValidEmojiNames());
GfmAutoComplete.glEmojiTag = glEmojiTag;
})
.catch(() => {
this.isLoadingData[at] = false;
});
})
.catch(() => {
this.isLoadingData[at] = false;
});
} else if (dataSource) {
AjaxCache.retrieve(dataSource, true)
.then(data => {
this.loadData($input, at, data);
})
.catch(() => {
this.isLoadingData[at] = false;
});
} else {
this.isLoadingData[at] = false;
}
}
loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
$input.atwho('load', at, data);
// This trigger at.js again
// otherwise we would be stuck with loading until the user types
return $input.trigger('keyup');
}
clearCache() {
this.cachedData = {};
}
destroy() {
this.input.each((i, input) => {
const $input = $(input);
$input.atwho('destroy');
});
}
static isLoading(data) {
let dataToInspect = data;
if (data && data.length > 0) {
[dataToInspect] = data;
}
const loadingState = GfmAutoComplete.defaultLoadingData[0];
return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState);
}
static defaultMatcher(flag, subtext, controllers) {
// The below is taken from At.js source
// Tweaked to commands to start without a space only if char before is a non-word character
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
.replace(/[$]/, '\\$&');
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
const accentAChar = decodeURI('%C3%80');
const accentYChar = decodeURI('%C3%BF');
const regexp = new RegExp(
`^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`,
'gi',
);
return regexp.exec(targetSubtext);
}
}
GfmAutoComplete.regexSubtext = new RegExp(/\s+/g);
GfmAutoComplete.defaultLoadingData = ['loading'];
GfmAutoComplete.atTypeMap = {
':': 'emojis',
'@': 'members',
'#': 'issues',
'!': 'mergeRequests',
'&': 'epics',
'~': 'labels',
'%': 'milestones',
'/': 'commands',
$: 'snippets',
};
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
templateFunction(name) {
// glEmojiTag helper is loaded on-demand in fetchData()
if (GfmAutoComplete.glEmojiTag) {
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
}
return `<li>${name}</li>`;
},
};
// Team Members
GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title }) {
return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small></li>`;
},
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
return `<li><span class="dropdown-label-box" style="background: ${_.escape(
color,
)}"></span> ${_.escape(title)}</li>`;
},
};
// Issues, MergeRequests and Snippets
GfmAutoComplete.Issues = {
insertTemplateFunction(value) {
// eslint-disable-next-line no-template-curly-in-string
return value.reference || '${atwho-at}${id}';
},
templateFunction({ id, title, reference }) {
return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
},
};
// Milestones
GfmAutoComplete.Milestones = {
templateFunction(title) {
return `<li>${_.escape(title)}</li>`;
},
};
GfmAutoComplete.Loading = {
template:
'<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>',
};
export default GfmAutoComplete;