diff --git a/TODO b/TODO
index 4a642b97..03f12696 100644
--- a/TODO
+++ b/TODO
@@ -20,8 +20,9 @@ everything related to posts:
(move post snapshot factory methods to PostService)
everything related to tags:
+ - automatic tag removal
+ - tag refresh when editing post
- tag listing
- - tag autocompletion (difficult)
- time of last tag usage
- time of tag addition
- mass tag
diff --git a/public_html/css/tags.css b/public_html/css/tags.css
new file mode 100644
index 00000000..9c522f91
--- /dev/null
+++ b/public_html/css/tags.css
@@ -0,0 +1,23 @@
+.autocomplete {
+ position: absolute;
+ display: none;
+ z-index: 10;
+}
+.autocomplete ul {
+ list-style-type: none;
+ padding: 0 0 !important;
+ margin: 0 !important;
+ border: 2px solid #5da;
+ background: white;
+ display: block !important;
+ text-align: left;
+}
+
+.autocomplete li {
+ margin: 0;
+ padding: 0.1em 0.5em !important;
+}
+
+.autocomplete li.active {
+ background: #5da;
+}
diff --git a/public_html/index.html b/public_html/index.html
index dbef564a..1ca3f8e9 100644
--- a/public_html/index.html
+++ b/public_html/index.html
@@ -37,6 +37,7 @@
+
@@ -86,9 +87,11 @@
+
+
diff --git a/public_html/js/Controls/AutoCompleteInput.js b/public_html/js/Controls/AutoCompleteInput.js
new file mode 100644
index 00000000..26a027da
--- /dev/null
+++ b/public_html/js/Controls/AutoCompleteInput.js
@@ -0,0 +1,223 @@
+var App = App || {};
+App.Controls = App.Controls || {};
+
+App.Controls.AutoCompleteInput = function($input) {
+ var _ = App.DI.get('_');
+ var jQuery = App.DI.get('jQuery');
+ var tagList = App.DI.get('tagList');
+
+ var KEY_RETURN = 13;
+ var KEY_ESCAPE = 27;
+ var KEY_UP = 38;
+ var KEY_DOWN = 40;
+
+ var options = {
+ caseSensitive: false,
+ source: null,
+ maxResults: 15,
+ minLengthToArbitrarySearch: 3,
+ onApply: null,
+ additionalFilter: null,
+ };
+ var showTimeout = null;
+ var cachedSource = null;
+ var results = [];
+ var activeResult = -1;
+
+ if ($input.length === 0) {
+ throw new Error('Input element was not found');
+ }
+ if ($input.length > 1) {
+ throw new Error('Cannot add autocompletion to more than one element at once');
+ }
+ if ($input.attr('data-autocomplete')) {
+ throw new Error('Autocompletion was already added for this element');
+ }
+ $input.attr('data-autocomplete', true);
+ $input.attr('autocomplete', 'off');
+
+ var $div = jQuery('
');
+ var $list = jQuery('
');
+ $div.addClass('autocomplete');
+ $div.append($list);
+ jQuery(document.body).append($div);
+
+ function getSource() {
+ if (cachedSource) {
+ return cachedSource;
+ } else {
+ var source = source || tagList.getTags();
+ source = _.pairs(source);
+ source = _.sortBy(source, function(a) { return -a[1]; });
+ source = _.filter(source, function(a) { return a[1] > 0; });
+ source = _.map(source, function(a) { return [a[0], a[0] + ' (' + a[1] + ')']; });
+ cachedSource = source;
+ return source;
+ }
+ }
+
+ $input.bind('keydown', function(e) {
+ if (isShown() && e.which === KEY_ESCAPE) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ hide();
+ } else if (isShown() && e.which === KEY_DOWN) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ selectNext();
+ } else if (isShown() && e.which === KEY_UP) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ selectPrevious();
+ } else if (isShown() && e.which === KEY_RETURN && activeResult >= 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ applyAutocomplete();
+ hide();
+ } else {
+ window.clearTimeout(showTimeout);
+ showTimeout = window.setTimeout(showOrHide, 250);
+ }
+ });
+
+ $input.blur(function(e) {
+ window.clearTimeout(showTimeout);
+ window.setTimeout(function() { hide(); }, 50);
+ });
+
+ function getSelectionStart(){
+ var input = $input.get(0);
+ if (!input) {
+ return;
+ }
+ if ('selectionStart' in input) {
+ return input.selectionStart;
+ } else if (document.selection) {
+ input.focus();
+ var sel = document.selection.createRange();
+ var selLen = document.selection.createRange().text.length;
+ sel.moveStart('character', -input.value.length);
+ return sel.text.length - selLen;
+ } else {
+ return 0;
+ }
+ }
+
+ function getTextToFind() {
+ var val = $input.val();
+ var start = getSelectionStart();
+ return val.substring(0, start).replace(/.*\s/, '');
+ }
+
+ function showOrHide() {
+ var textToFind = getTextToFind();
+ if (textToFind.length === 0) {
+ hide();
+ } else {
+ updateResults(textToFind);
+ refreshList();
+ }
+ }
+
+ function isShown() {
+ return $div.is(':visible');
+ }
+
+ function hide() {
+ $div.hide();
+ }
+
+ function selectPrevious() {
+ select(activeResult === -1 ? results.length - 1 : activeResult - 1);
+ }
+
+ function selectNext() {
+ select(activeResult === -1 ? 0 : activeResult + 1);
+ }
+
+ function select(newActiveResult) {
+ if (newActiveResult >= 0 && newActiveResult < results.length) {
+ activeResult = newActiveResult;
+ refreshActiveResult();
+ } else {
+ activeResult = - 1;
+ refreshActiveResult();
+ }
+ }
+
+ function getResultsFilter(textToFind) {
+ if (textToFind.length < options.minLengthToArbitrarySearch) {
+ return options.caseSensitive ?
+ function(resultItem) { return resultItem[0].indexOf(textToFind) === 0; } :
+ function(resultItem) { return resultItem[0].toLowerCase().indexOf(textToFind.toLowerCase()) === 0; };
+ } else {
+ return options.caseSensitive ?
+ function(resultItem) { return resultItem[0].indexOf(textToFind) >= 0; } :
+ function(resultItem) { return resultItem[0].toLowerCase().indexOf(textToFind.toLowerCase()) >= 0; };
+ }
+ }
+
+ function updateResults(textToFind) {
+ var source = getSource();
+ var filter = getResultsFilter(textToFind);
+ results = _.filter(source, filter);
+ if (options.additionalFilter) {
+ results = options.additionalFilter(results);
+ }
+ results = results.slice(0, options.maxResults);
+ activeResult = -1;
+ }
+
+ function applyAutocomplete() {
+ if (options.onApply) {
+ options.onApply(results[activeResult][0]);
+ } else {
+ var val = $input.val();
+ var start = getSelectionStart();
+ var prefix = '';
+ var suffix = val.substring(start);
+ var middle = val.substring(0, start);
+ var index = middle.lastIndexOf(' ');
+ if (index !== -1) {
+ prefix = val.substring(0, index + 1);
+ middle = val.substring(index + 1);
+ }
+ $input.val(prefix + results[activeResult][0] + ' ' + suffix.trimLeft());
+ $input.focus();
+ }
+ }
+
+ function refreshList() {
+ if (results.length === 0) {
+ hide();
+ return;
+ }
+
+ $list.empty();
+ _.each(results, function(resultItem) {
+ var $listItem = jQuery('');
+ $listItem.text(resultItem[1]);
+ $listItem.attr('data-key', resultItem[0]);
+ $list.append($listItem);
+ });
+ refreshActiveResult();
+ $div.css({
+ left: ($input.offset().left) + 'px',
+ top: ($input.offset().top + $input.outerHeight() - 2) + 'px',
+ });
+ $div.show();
+ }
+
+ function refreshActiveResult() {
+ $list.find('li.active').removeClass('active');
+ if (activeResult >= 0) {
+ $list.find('li').eq(activeResult).addClass('active');
+ }
+ }
+
+ return options;
+};
diff --git a/public_html/js/Controls/FileDropper.js b/public_html/js/Controls/FileDropper.js
index 3344023a..007b02b6 100644
--- a/public_html/js/Controls/FileDropper.js
+++ b/public_html/js/Controls/FileDropper.js
@@ -1,10 +1,9 @@
var App = App || {};
App.Controls = App.Controls || {};
-App.Controls.FileDropper = function(
- $fileInput,
- _,
- jQuery) {
+App.Controls.FileDropper = function($fileInput) {
+ var _ = App.DI.get('_');
+ var jQuery = App.DI.get('jQuery');
var options = {
onChange: null,
@@ -63,7 +62,4 @@ App.Controls.FileDropper = function(
});
return options;
-
};
-
-App.DI.register('fileDropper', App.Controls.FileDropper);
diff --git a/public_html/js/Controls/TagInput.js b/public_html/js/Controls/TagInput.js
index d2c8bd52..1c1fa5db 100644
--- a/public_html/js/Controls/TagInput.js
+++ b/public_html/js/Controls/TagInput.js
@@ -1,12 +1,9 @@
var App = App || {};
App.Controls = App.Controls || {};
-//todo: autocomplete
-
-App.Controls.TagInput = function(
- $underlyingInput,
- _,
- jQuery) {
+App.Controls.TagInput = function($underlyingInput) {
+ var _ = App.DI.get('_');
+ var jQuery = App.DI.get('jQuery');
var KEY_RETURN = 13;
var KEY_SPACE = 32;
@@ -48,21 +45,36 @@ App.Controls.TagInput = function(
});
$input.attr('placeholder', $underlyingInput.attr('placeholder'));
- var tagsToAdd = $underlyingInput.val().split(/\s+/);
- _.map(tagsToAdd, addTag);
+ addTagsFromText($underlyingInput.val());
$underlyingInput.val('');
- $input.unbind('focus').bind('focus', function(e) {
+ initAutocomplete();
+
+ function initAutocomplete() {
+ var autocomplete = new App.Controls.AutoCompleteInput($input);
+ autocomplete.onApply = function(text) {
+ addTagsFromText(text);
+ $input.val('');
+ };
+ autocomplete.additionalFilter = function(results) {
+ var tags = getTags();
+ return _.filter(results, function(resultItem) {
+ return !_.contains(tags, resultItem[0]);
+ });
+ };
+ }
+
+ $input.bind('focus', function(e) {
$wrapper.addClass('focused');
});
- $input.unbind('blur').bind('blur', function(e) {
+ $input.bind('blur', function(e) {
$wrapper.removeClass('focused');
var tag = $input.val();
addTag(tag);
$input.val('');
});
- $input.unbind('paste').bind('paste', function(e) {
+ $input.bind('paste', function(e) {
e.preventDefault();
var pastedText;
if (window.clipboardData) {
@@ -82,7 +94,7 @@ App.Controls.TagInput = function(
$input.val(lastTag);
});
- $input.unbind('keydown').bind('keydown', function(e) {
+ $input.bind('keydown', function(e) {
if (_.contains(inputConfirmKeys, e.which) && !$input.val()) {
e.preventDefault();
if (typeof(options.inputConfirmed) !== 'undefined') {
@@ -99,6 +111,11 @@ App.Controls.TagInput = function(
}
});
+ function addTagsFromText(text) {
+ var tagsToAdd = text.split(/\s+/);
+ _.map(tagsToAdd, addTag);
+ }
+
function addTag(tag) {
tag = tag.trim();
if (tag.length === 0) {
@@ -185,5 +202,3 @@ App.Controls.TagInput = function(
});
return options;
};
-
-App.DI.register('tagInput', App.Controls.TagInput);
diff --git a/public_html/js/Presenters/PostListPresenter.js b/public_html/js/Presenters/PostListPresenter.js
index 52367a7a..de1e21a2 100644
--- a/public_html/js/Presenters/PostListPresenter.js
+++ b/public_html/js/Presenters/PostListPresenter.js
@@ -70,6 +70,7 @@ App.Presenters.PostListPresenter = function(
function render() {
$el.html(templates.list());
$searchInput = $el.find('input[name=query]');
+ App.Controls.AutoCompleteInput($searchInput);
$searchInput.val(searchArgs.query);
$searchInput.keydown(searchInputKeyPressed);
@@ -80,7 +81,7 @@ App.Presenters.PostListPresenter = function(
});
keyboard.keyup('q', function() {
- $searchInput.eq(0).focus();
+ $searchInput.eq(0).focus().select();
});
}
@@ -95,7 +96,7 @@ App.Presenters.PostListPresenter = function(
var $post = jQuery('- ' + templates.listItem({
searchArgs: searchArgs,
post: post,
- }) + '
')
+ }) + '');
util.loadImagesNicely($post.find('img'));
$target.append($post);
});
@@ -116,7 +117,7 @@ App.Presenters.PostListPresenter = function(
function updateSearch() {
$searchInput.blur();
pagerPresenter.setSearchParams({
- query: $searchInput.val(),
+ query: $searchInput.val().trim(),
order: searchArgs.order});
}
diff --git a/public_html/js/Presenters/PostPresenter.js b/public_html/js/Presenters/PostPresenter.js
index 3004af90..2edef41c 100644
--- a/public_html/js/Presenters/PostPresenter.js
+++ b/public_html/js/Presenters/PostPresenter.js
@@ -135,14 +135,14 @@ App.Presenters.PostPresenter = function(
$messages = $el.find('.messages');
if (editPrivileges.canChangeTags) {
- tagInput = App.Controls.TagInput($el.find('form [name=tags]'), _, jQuery);
+ tagInput = new App.Controls.TagInput($el.find('form [name=tags]'));
tagInput.inputConfirmed = editPost;
}
- postContentFileDropper = new App.Controls.FileDropper($el.find('form [name=content]'), _, jQuery);
+ postContentFileDropper = new App.Controls.FileDropper($el.find('form [name=content]'));
postContentFileDropper.onChange = postContentChanged;
postContentFileDropper.setNames = true;
- postThumbnailFileDropper = new App.Controls.FileDropper($el.find('form [name=thumbnail]'), _, jQuery);
+ postThumbnailFileDropper = new App.Controls.FileDropper($el.find('form [name=thumbnail]'));
postThumbnailFileDropper.onChange = postThumbnailChanged;
postThumbnailFileDropper.setNames = true;
diff --git a/public_html/js/Presenters/PostUploadPresenter.js b/public_html/js/Presenters/PostUploadPresenter.js
index 0d1bbcfc..ebae81f4 100644
--- a/public_html/js/Presenters/PostUploadPresenter.js
+++ b/public_html/js/Presenters/PostUploadPresenter.js
@@ -41,8 +41,8 @@ App.Presenters.PostUploadPresenter = function(
}));
$messages = $el.find('.messages');
- tagInput = new App.Controls.TagInput($el.find('form [name=tags]'), _, jQuery);
- fileDropper = App.Controls.FileDropper($el.find('[name=post-content]'), _, jQuery);
+ tagInput = new App.Controls.TagInput($el.find('form [name=tags]'));
+ fileDropper = new App.Controls.FileDropper($el.find('[name=post-content]'));
fileDropper.onChange = fileHandlerChanged;
$el.find('.url-handler input').keydown(urlHandlerKeyPressed);
diff --git a/public_html/js/Presenters/UserAccountSettingsPresenter.js b/public_html/js/Presenters/UserAccountSettingsPresenter.js
index 10b28caf..05c2e546 100644
--- a/public_html/js/Presenters/UserAccountSettingsPresenter.js
+++ b/public_html/js/Presenters/UserAccountSettingsPresenter.js
@@ -54,7 +54,7 @@ App.Presenters.UserAccountSettingsPresenter = function(
$el.find('form').submit(accountSettingsFormSubmitted);
$el.find('form [name=avatar-style]').change(avatarStyleChanged);
avatarStyleChanged();
- fileDropper = new App.Controls.FileDropper($el.find('[name=avatar-content]'), _, jQuery);
+ fileDropper = new App.Controls.FileDropper($el.find('[name=avatar-content]'));
fileDropper.onChange = avatarContentChanged;
fileDropper.setNames = true;
}
diff --git a/public_html/js/Services/TagList.js b/public_html/js/Services/TagList.js
new file mode 100644
index 00000000..6ba1691d
--- /dev/null
+++ b/public_html/js/Services/TagList.js
@@ -0,0 +1,27 @@
+var App = App || {};
+App.Services = App.Services || {};
+
+App.Services.TagList = function(jQuery) {
+ var tags = [];
+
+ jQuery.ajax({
+ success: function(data, textStatus, xhr) {
+ tags = data;
+ },
+ error: function(xhr, textStatus, errorThrown) {
+ console.log(new Error(errorThrown));
+ },
+ type: 'GET',
+ url: '/data/tags.json',
+ });
+
+ function getTags() {
+ return tags;
+ }
+
+ return {
+ getTags: getTags,
+ };
+};
+
+App.DI.registerSingleton('tagList', ['jQuery'], App.Services.TagList);