Added autocompletion

This commit is contained in:
Marcin Kurczewski 2014-10-05 13:25:40 +02:00
parent b57fee0ad8
commit adfc120642
11 changed files with 320 additions and 31 deletions

3
TODO
View file

@ -20,8 +20,9 @@ everything related to posts:
(move post snapshot factory methods to PostService) (move post snapshot factory methods to PostService)
everything related to tags: everything related to tags:
- automatic tag removal
- tag refresh when editing post
- tag listing - tag listing
- tag autocompletion (difficult)
- time of last tag usage - time of last tag usage
- time of tag addition - time of tag addition
- mass tag - mass tag

23
public_html/css/tags.css Normal file
View file

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

View file

@ -37,6 +37,7 @@
<link rel="stylesheet" type="text/css" href="/css/home.css"/> <link rel="stylesheet" type="text/css" href="/css/home.css"/>
<link rel="stylesheet" type="text/css" href="/css/history.css"/> <link rel="stylesheet" type="text/css" href="/css/history.css"/>
<link rel="stylesheet" type="text/css" href="/css/comments.css"/> <link rel="stylesheet" type="text/css" href="/css/comments.css"/>
<link rel="stylesheet" type="text/css" href="/css/tags.css"/>
<!-- /build --> <!-- /build -->
<!-- build:remove --> <!-- build:remove -->
@ -86,9 +87,11 @@
<script type="text/javascript" src="/js/Pager.js"></script> <script type="text/javascript" src="/js/Pager.js"></script>
<script type="text/javascript" src="/js/BrowsingSettings.js"></script> <script type="text/javascript" src="/js/BrowsingSettings.js"></script>
<script type="text/javascript" src="/js/Controls/FileDropper.js"></script> <script type="text/javascript" src="/js/Controls/FileDropper.js"></script>
<script type="text/javascript" src="/js/Controls/AutoCompleteInput.js"></script>
<script type="text/javascript" src="/js/Controls/TagInput.js"></script> <script type="text/javascript" src="/js/Controls/TagInput.js"></script>
<script type="text/javascript" src="/js/PresenterManager.js"></script> <script type="text/javascript" src="/js/PresenterManager.js"></script>
<script type="text/javascript" src="/js/Services/TagList.js"></script>
<script type="text/javascript" src="/js/Services/PostsAroundCalculator.js"></script> <script type="text/javascript" src="/js/Services/PostsAroundCalculator.js"></script>
<script type="text/javascript" src="/js/Presenters/TopNavigationPresenter.js"></script> <script type="text/javascript" src="/js/Presenters/TopNavigationPresenter.js"></script>

View file

@ -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('<div>');
var $list = jQuery('<ul>');
$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('<li/>');
$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;
};

View file

@ -1,10 +1,9 @@
var App = App || {}; var App = App || {};
App.Controls = App.Controls || {}; App.Controls = App.Controls || {};
App.Controls.FileDropper = function( App.Controls.FileDropper = function($fileInput) {
$fileInput, var _ = App.DI.get('_');
_, var jQuery = App.DI.get('jQuery');
jQuery) {
var options = { var options = {
onChange: null, onChange: null,
@ -63,7 +62,4 @@ App.Controls.FileDropper = function(
}); });
return options; return options;
}; };
App.DI.register('fileDropper', App.Controls.FileDropper);

View file

@ -1,12 +1,9 @@
var App = App || {}; var App = App || {};
App.Controls = App.Controls || {}; App.Controls = App.Controls || {};
//todo: autocomplete App.Controls.TagInput = function($underlyingInput) {
var _ = App.DI.get('_');
App.Controls.TagInput = function( var jQuery = App.DI.get('jQuery');
$underlyingInput,
_,
jQuery) {
var KEY_RETURN = 13; var KEY_RETURN = 13;
var KEY_SPACE = 32; var KEY_SPACE = 32;
@ -48,21 +45,36 @@ App.Controls.TagInput = function(
}); });
$input.attr('placeholder', $underlyingInput.attr('placeholder')); $input.attr('placeholder', $underlyingInput.attr('placeholder'));
var tagsToAdd = $underlyingInput.val().split(/\s+/); addTagsFromText($underlyingInput.val());
_.map(tagsToAdd, addTag);
$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'); $wrapper.addClass('focused');
}); });
$input.unbind('blur').bind('blur', function(e) { $input.bind('blur', function(e) {
$wrapper.removeClass('focused'); $wrapper.removeClass('focused');
var tag = $input.val(); var tag = $input.val();
addTag(tag); addTag(tag);
$input.val(''); $input.val('');
}); });
$input.unbind('paste').bind('paste', function(e) { $input.bind('paste', function(e) {
e.preventDefault(); e.preventDefault();
var pastedText; var pastedText;
if (window.clipboardData) { if (window.clipboardData) {
@ -82,7 +94,7 @@ App.Controls.TagInput = function(
$input.val(lastTag); $input.val(lastTag);
}); });
$input.unbind('keydown').bind('keydown', function(e) { $input.bind('keydown', function(e) {
if (_.contains(inputConfirmKeys, e.which) && !$input.val()) { if (_.contains(inputConfirmKeys, e.which) && !$input.val()) {
e.preventDefault(); e.preventDefault();
if (typeof(options.inputConfirmed) !== 'undefined') { 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) { function addTag(tag) {
tag = tag.trim(); tag = tag.trim();
if (tag.length === 0) { if (tag.length === 0) {
@ -185,5 +202,3 @@ App.Controls.TagInput = function(
}); });
return options; return options;
}; };
App.DI.register('tagInput', App.Controls.TagInput);

View file

@ -70,6 +70,7 @@ App.Presenters.PostListPresenter = function(
function render() { function render() {
$el.html(templates.list()); $el.html(templates.list());
$searchInput = $el.find('input[name=query]'); $searchInput = $el.find('input[name=query]');
App.Controls.AutoCompleteInput($searchInput);
$searchInput.val(searchArgs.query); $searchInput.val(searchArgs.query);
$searchInput.keydown(searchInputKeyPressed); $searchInput.keydown(searchInputKeyPressed);
@ -80,7 +81,7 @@ App.Presenters.PostListPresenter = function(
}); });
keyboard.keyup('q', 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('<li>' + templates.listItem({ var $post = jQuery('<li>' + templates.listItem({
searchArgs: searchArgs, searchArgs: searchArgs,
post: post, post: post,
}) + '</li>') }) + '</li>');
util.loadImagesNicely($post.find('img')); util.loadImagesNicely($post.find('img'));
$target.append($post); $target.append($post);
}); });
@ -116,7 +117,7 @@ App.Presenters.PostListPresenter = function(
function updateSearch() { function updateSearch() {
$searchInput.blur(); $searchInput.blur();
pagerPresenter.setSearchParams({ pagerPresenter.setSearchParams({
query: $searchInput.val(), query: $searchInput.val().trim(),
order: searchArgs.order}); order: searchArgs.order});
} }

View file

@ -135,14 +135,14 @@ App.Presenters.PostPresenter = function(
$messages = $el.find('.messages'); $messages = $el.find('.messages');
if (editPrivileges.canChangeTags) { 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; 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.onChange = postContentChanged;
postContentFileDropper.setNames = true; 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.onChange = postThumbnailChanged;
postThumbnailFileDropper.setNames = true; postThumbnailFileDropper.setNames = true;

View file

@ -41,8 +41,8 @@ App.Presenters.PostUploadPresenter = function(
})); }));
$messages = $el.find('.messages'); $messages = $el.find('.messages');
tagInput = new App.Controls.TagInput($el.find('form [name=tags]'), _, jQuery); tagInput = new App.Controls.TagInput($el.find('form [name=tags]'));
fileDropper = App.Controls.FileDropper($el.find('[name=post-content]'), _, jQuery); fileDropper = new App.Controls.FileDropper($el.find('[name=post-content]'));
fileDropper.onChange = fileHandlerChanged; fileDropper.onChange = fileHandlerChanged;
$el.find('.url-handler input').keydown(urlHandlerKeyPressed); $el.find('.url-handler input').keydown(urlHandlerKeyPressed);

View file

@ -54,7 +54,7 @@ App.Presenters.UserAccountSettingsPresenter = function(
$el.find('form').submit(accountSettingsFormSubmitted); $el.find('form').submit(accountSettingsFormSubmitted);
$el.find('form [name=avatar-style]').change(avatarStyleChanged); $el.find('form [name=avatar-style]').change(avatarStyleChanged);
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.onChange = avatarContentChanged;
fileDropper.setNames = true; fileDropper.setNames = true;
} }

View file

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