FEATURE: Autolinking to category using hashtags.

This commit is contained in:
Guo Xiang Tan
2015-12-28 14:28:16 +08:00
parent b8471177dc
commit c1dbf5c1c4
20 changed files with 365 additions and 14 deletions

View File

@@ -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));
},

View File

@@ -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;

View File

@@ -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: ":",

View File

@@ -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];
}
}
});

View File

@@ -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);

View File

@@ -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);
});
}

View File

@@ -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-*');

View File

@@ -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;

View File

@@ -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: '/' });

View File

@@ -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)}`);
}
});