mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Autolinking to category using hashtags.
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import { categoryBadgeHTML } from 'discourse/helpers/category-link';
|
||||
import Category from 'discourse/models/category';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
|
||||
_initializeAutocomplete: function() {
|
||||
const self = this,
|
||||
template = this.container.lookup('template:category-group-autocomplete.raw'),
|
||||
regexp = new RegExp("href=['\"]" + Discourse.getURL('/c/') + "([^'\"]+)");
|
||||
regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`);
|
||||
|
||||
this.$('input').autocomplete({
|
||||
items: this.get('categories'),
|
||||
single: false,
|
||||
allowAny: false,
|
||||
dataSource(term){
|
||||
return Discourse.Category.list().filter(function(category){
|
||||
return Category.list().filter(function(category){
|
||||
const regex = new RegExp(term, "i");
|
||||
return category.get("name").match(regex) &&
|
||||
!_.contains(self.get('blacklist') || [], category) &&
|
||||
@@ -22,7 +23,7 @@ export default Ember.Component.extend({
|
||||
onChangeItems(items) {
|
||||
const categories = _.map(items, function(link) {
|
||||
const slug = link.match(regexp)[1];
|
||||
return Discourse.Category.findSingleBySlug(slug);
|
||||
return Category.findSingleBySlug(slug);
|
||||
});
|
||||
Em.run.next(() => self.set("categories", categories));
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import userSearch from 'discourse/lib/user-search';
|
||||
import { default as computed, on } from 'ember-addons/ember-computed-decorators';
|
||||
import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions';
|
||||
import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse/lib/link-category-hashtags';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
classNames: ['wmd-controls'],
|
||||
@@ -111,13 +112,19 @@ export default Ember.Component.extend({
|
||||
$preview.scrollTop(desired + 50);
|
||||
},
|
||||
|
||||
_renderUnseen: function($preview, unseen) {
|
||||
fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => {
|
||||
_renderUnseenMentions: function($preview, unseen) {
|
||||
fetchUnseenMentions($preview, unseen).then(() => {
|
||||
linkSeenMentions($preview, this.siteSettings);
|
||||
this._warnMentionedGroups($preview);
|
||||
});
|
||||
},
|
||||
|
||||
_renderUnseenCategoryHashtags: function($preview, unseen) {
|
||||
fetchUnseenCategoryHashtags(unseen).then(() => {
|
||||
linkSeenCategoryHashtags($preview);
|
||||
});
|
||||
},
|
||||
|
||||
_warnMentionedGroups($preview) {
|
||||
Ember.run.scheduleOnce('afterRender', () => {
|
||||
this._warnedMentions = this._warnedMentions || [];
|
||||
@@ -386,11 +393,17 @@ export default Ember.Component.extend({
|
||||
// Paint mentions
|
||||
const unseen = linkSeenMentions($preview, this.siteSettings);
|
||||
if (unseen.length) {
|
||||
Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500);
|
||||
Ember.run.debounce(this, this._renderUnseenMentions, $preview, unseen, 500);
|
||||
}
|
||||
|
||||
this._warnMentionedGroups($preview);
|
||||
|
||||
// Paint category hashtags
|
||||
const unseenHashtags = linkSeenCategoryHashtags($preview);
|
||||
if (unseenHashtags.length) {
|
||||
Ember.run.debounce(this, this._renderUnseenCategoryHashtags, $preview, unseenHashtags, 500);
|
||||
}
|
||||
|
||||
const post = this.get('composer.post');
|
||||
let refresh = false;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import loadScript from 'discourse/lib/load-script';
|
||||
import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators';
|
||||
import { showSelector } from "discourse/lib/emoji/emoji-toolbar";
|
||||
import Category from 'discourse/models/category';
|
||||
|
||||
// Our head can be a static string or a function that returns a string
|
||||
// based on input (like for numbered lists).
|
||||
@@ -175,7 +176,11 @@ export default Ember.Component.extend({
|
||||
|
||||
@on('didInsertElement')
|
||||
_startUp() {
|
||||
this._applyEmojiAutocomplete();
|
||||
const container = this.get('container'),
|
||||
$editorInput = this.$('.d-editor-input');
|
||||
|
||||
this._applyEmojiAutocomplete(container, $editorInput);
|
||||
this._applyCategoryHashtagAutocomplete(container, $editorInput);
|
||||
|
||||
loadScript('defer/html-sanitizer-bundle').then(() => this.set('ready', true));
|
||||
|
||||
@@ -243,14 +248,52 @@ export default Ember.Component.extend({
|
||||
Ember.run.debounce(this, this._updatePreview, 30);
|
||||
},
|
||||
|
||||
_applyEmojiAutocomplete() {
|
||||
_applyCategoryHashtagAutocomplete(container, $editorInput) {
|
||||
const template = container.lookup('template:category-group-autocomplete.raw');
|
||||
|
||||
$editorInput.autocomplete({
|
||||
template: template,
|
||||
key: '#',
|
||||
transformComplete(category) {
|
||||
return category.get('slug');
|
||||
},
|
||||
dataSource(term) {
|
||||
return Category.list().filter(category => {
|
||||
const regexp = new RegExp(term, 'i');
|
||||
return category.get('name').match(regexp);
|
||||
});
|
||||
},
|
||||
triggerRule(textarea, opts) {
|
||||
const result = Discourse.Utilities.caretRowCol(textarea);
|
||||
const row = result.rowNum;
|
||||
var col = result.colNum;
|
||||
var line = textarea.value.split("\n")[row - 1];
|
||||
|
||||
if (opts && opts.backSpace) {
|
||||
col = col - 1;
|
||||
line = line.slice(0, line.length - 1);
|
||||
|
||||
// Don't trigger autocomplete when backspacing into a `#category |` => `#category|`
|
||||
if (/^#{1}\w+/.test(line)) return false;
|
||||
}
|
||||
|
||||
if (col < 6) {
|
||||
// Don't trigger autocomplete when ATX-style headers are used
|
||||
return (line.slice(0, col) !== "#".repeat(col));
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_applyEmojiAutocomplete(container, $editorInput) {
|
||||
if (!this.siteSettings.enable_emoji) { return; }
|
||||
|
||||
const container = this.container;
|
||||
const template = container.lookup('template:emoji-selector-autocomplete.raw');
|
||||
const self = this;
|
||||
|
||||
this.$('.d-editor-input').autocomplete({
|
||||
$editorInput.autocomplete({
|
||||
template: template,
|
||||
key: ":",
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
Supports Discourse's category hashtags (#category-slug) for automatically
|
||||
generating a link to the category.
|
||||
**/
|
||||
Discourse.Dialect.inlineRegexp({
|
||||
start: '#',
|
||||
matcher: /^#([A-Za-z0-9][A-Za-z0-9\-]{0,40}[A-Za-z0-9])/,
|
||||
spaceOrTagBoundary: true,
|
||||
|
||||
emitter: function(matches) {
|
||||
var slug = matches[1],
|
||||
hashtag = matches[0],
|
||||
attributeClass = 'hashtag',
|
||||
categoryHashtagLookup = this.dialect.options.categoryHashtagLookup,
|
||||
result = categoryHashtagLookup && categoryHashtagLookup(slug);
|
||||
|
||||
if (result && result[0] === "category") {
|
||||
return ['a', { class: attributeClass, href: result[1] }, hashtag];
|
||||
} else {
|
||||
return ['span', { class: attributeClass }, hashtag];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -282,6 +282,14 @@ export default function(options) {
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const checkTriggerRule = (opts) => {
|
||||
if (options.triggerRule) {
|
||||
return options.triggerRule(me[0], opts);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$(this).on('keypress.autocomplete', function(e) {
|
||||
var caretPosition, term;
|
||||
|
||||
@@ -289,7 +297,7 @@ export default function(options) {
|
||||
if (options.key && e.which === options.key.charCodeAt(0)) {
|
||||
caretPosition = Discourse.Utilities.caretPosition(me[0]);
|
||||
var prevChar = me.val().charAt(caretPosition - 1);
|
||||
if (!prevChar || allowedLettersRegex.test(prevChar)) {
|
||||
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
|
||||
completeStart = completeEnd = caretPosition;
|
||||
updateAutoComplete(options.dataSource(""));
|
||||
}
|
||||
@@ -343,7 +351,7 @@ export default function(options) {
|
||||
stopFound = prev === options.key;
|
||||
if (stopFound) {
|
||||
prev = me[0].value[c - 1];
|
||||
if (!prev || allowedLettersRegex.test(prev)) {
|
||||
if (checkTriggerRule({ backSpace: true }) && (!prev || allowedLettersRegex.test(prev))) {
|
||||
completeStart = c;
|
||||
caretPosition = completeEnd = initial;
|
||||
term = me[0].value.substring(c + 1, initial);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const validCategoryHashtags = {};
|
||||
const checkedCategoryHashtags = [];
|
||||
const testedKey = 'tested';
|
||||
const testedClass = `hashtag-${testedKey}`;
|
||||
|
||||
function replaceSpan($elem, categorySlug, categoryLink) {
|
||||
$elem.replaceWith(`<a href="${categoryLink}" class="hashtag">#${categorySlug}</a>`);
|
||||
}
|
||||
|
||||
function updateFound($hashtags, categorySlugs) {
|
||||
Ember.run.schedule('afterRender', () => {
|
||||
$hashtags.each((index, hashtag) => {
|
||||
const categorySlug = categorySlugs[index];
|
||||
const link = validCategoryHashtags[categorySlug];
|
||||
const $hashtag = $(hashtag);
|
||||
|
||||
if (link) {
|
||||
replaceSpan($hashtag, categorySlug, link);
|
||||
} else if (checkedCategoryHashtags.indexOf(categorySlug) !== -1) {
|
||||
$hashtag.addClass(testedClass);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export function linkSeenCategoryHashtags($elem) {
|
||||
const $hashtags = $(`span.hashtag:not(.${testedClass})`, $elem);
|
||||
const unseen = [];
|
||||
|
||||
if ($hashtags.length) {
|
||||
const categorySlugs = $hashtags.map((_, hashtag) => $(hashtag).text().substr(1));
|
||||
if (categorySlugs.length) {
|
||||
_.uniq(categorySlugs).forEach((categorySlug) => {
|
||||
if (checkedCategoryHashtags.indexOf(categorySlug) === -1) {
|
||||
unseen.push(categorySlug);
|
||||
}
|
||||
});
|
||||
}
|
||||
updateFound($hashtags, categorySlugs);
|
||||
}
|
||||
|
||||
return unseen;
|
||||
};
|
||||
|
||||
export function fetchUnseenCategoryHashtags(categorySlugs) {
|
||||
return Discourse.ajax("/category_hashtags/check", { data: { category_slugs: categorySlugs } })
|
||||
.then((response) => {
|
||||
response.valid.forEach((category) => {
|
||||
validCategoryHashtags[category.slug] = category.url;
|
||||
});
|
||||
checkedCategoryHashtags.push.apply(checkedCategoryHashtags, categorySlugs);
|
||||
});
|
||||
}
|
||||
@@ -239,6 +239,7 @@ Discourse.Markdown.whiteListTag('a', 'class', 'attachment');
|
||||
Discourse.Markdown.whiteListTag('a', 'class', 'onebox');
|
||||
Discourse.Markdown.whiteListTag('a', 'class', 'mention');
|
||||
Discourse.Markdown.whiteListTag('a', 'class', 'mention-group');
|
||||
Discourse.Markdown.whiteListTag('a', 'class', 'hashtag');
|
||||
|
||||
Discourse.Markdown.whiteListTag('a', 'target', '_blank');
|
||||
Discourse.Markdown.whiteListTag('a', 'rel', 'nofollow');
|
||||
@@ -251,6 +252,7 @@ Discourse.Markdown.whiteListTag('div', 'class', 'title');
|
||||
Discourse.Markdown.whiteListTag('div', 'class', 'quote-controls');
|
||||
|
||||
Discourse.Markdown.whiteListTag('span', 'class', 'mention');
|
||||
Discourse.Markdown.whiteListTag('span', 'class', 'hashtag');
|
||||
Discourse.Markdown.whiteListTag('aside', 'class', 'quote');
|
||||
Discourse.Markdown.whiteListTag('aside', 'data-*');
|
||||
|
||||
|
||||
@@ -143,6 +143,19 @@ Discourse.Utilities = {
|
||||
return String(text).trim();
|
||||
},
|
||||
|
||||
// Determine the row and col of the caret in an element
|
||||
caretRowCol: function(el) {
|
||||
var caretPosition = Discourse.Utilities.caretPosition(el);
|
||||
var rows = el.value.slice(0, caretPosition).split("\n");
|
||||
var rowNum = rows.length;
|
||||
|
||||
var colNum = caretPosition - rows.splice(0, rowNum - 1).reduce(function(sum, row) {
|
||||
return sum + row.length + 1;
|
||||
}, 0);
|
||||
|
||||
return { rowNum: rowNum, colNum: colNum};
|
||||
},
|
||||
|
||||
// Determine the position of the caret in an element
|
||||
caretPosition: function(el) {
|
||||
var r, rc, re;
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function() {
|
||||
this.route('parentCategory', { path: '/c/:slug' });
|
||||
this.route('categoryNone', { path: '/c/:slug/none' });
|
||||
this.route('category', { path: '/c/:parentSlug/:slug' });
|
||||
this.route('categoryWithID', { path: '/c/:parentSlug/:slug/:id' });
|
||||
|
||||
// homepage
|
||||
this.route(Discourse.Utilities.defaultHomepage(), { path: '/' });
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Category from 'discourse/models/category';
|
||||
|
||||
export default Discourse.Route.extend({
|
||||
model: function(params) {
|
||||
return Category.findById(params.id);
|
||||
},
|
||||
|
||||
redirect: function(model) {
|
||||
this.transitionTo(`/c/${Category.slugFor(model)}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user