d07919de90
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
630 lines
19 KiB
JavaScript
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;
|