client/tags: show tag suggestions in tag input

This commit is contained in:
rr- 2016-07-30 18:08:49 +02:00
parent c1c47de3a5
commit 72072db078
4 changed files with 265 additions and 28 deletions

View file

@ -33,6 +33,9 @@ $new-tag-background-color = #DFC
$new-tag-text-color = black
$implied-tag-background-color = #FFC
$implied-tag-text-color = black
$tag-suggestions-background-color = $window-color
$tag-suggestions-header-color = #EEE
$tag-suggestions-border-color = #AAA
$duplicate-tag-background-color = #FDC
$duplicate-tag-text-color = black
$note-overlay-background-color = rgba(255, 255, 255, 0.3)

View file

@ -174,6 +174,7 @@ input:disabled
cursor: not-allowed
div.tag-input
position: relative
li
transition: background-color 0.5s linear
&.implication
@ -188,6 +189,71 @@ div.tag-input
ul
margin-top: 0.2em
.tag-suggestions
position: absolute
z-index: 5
top: 0
left: 100%
&:not(.shown)
display: none
.close
float: right
color: $inactive-link-color
.wrapper
margin-left: 0.5em
background: $tag-suggestions-background-color
border: 1px solid $tag-suggestions-border-color
width: 15em
word-break: break-all
p
background: $tag-suggestions-header-color
padding: 0.2em 1em
margin: 0
ul
margin: 0
overflow-y: auto
overflow-x: none
max-height: 20em
padding: 0.5em 1em 0 1em
li:last-child
border-bottom: 0.5em solid alpha($tag-suggestions-background-color, 0)
li
margin: 0
font-size: 90%
line-height: 1.3
a, span
display: inline-block
vertical-align: bottom
.add-tag
white-space: nowrap
overflow: hidden
max-width: 10em
text-overflow: ellipsis
.tag-weight
margin: 0 1em 0 0
p
margin: 0
&:before
margin-left: 0.5em
margin-top: 0.5em
position: absolute
display: block
background: $tag-suggestions-header-color
border-left: 1px solid $tag-suggestions-border-color
border-bottom: 1px solid $tag-suggestions-border-color
width: 0.707107em
height: 0.707107em
content: ' '
transform: rotate(45deg)
transform-origin: 0 0%
.append
color: $inactive-link-color
margin-left: 0.7em
font-size: 90%
ul.compact-tags
line-height: 130%
word-break: break-all
@ -195,9 +261,9 @@ ul.compact-tags
margin: 0
i
padding-right: 0.4em
.count
.append
color: $inactive-link-color
padding-left: 0.7em
margin-left: 0.7em
font-size: 90%
div.tag-relations
@ -285,7 +351,7 @@ input::-moz-focus-inner
* File dropper
*/
.file-dropper
background: white
background: $window-color
border: 3px dashed #eee
padding: 0.3em 0.5em
line-height: 140%
@ -307,7 +373,7 @@ input[type=file]:focus+.file-dropper,
.autocomplete
position: absolute
z-index: 10
background: white
background: $window-color
border: 2px solid $main-color
display: none
font-size: 0.95em

View file

@ -80,7 +80,7 @@
--><% if (ctx.canListPosts) { %><!--
--></a><!--
--><% } %><!--
--><span class='count'><%- ctx.getTagUsages(tag) %></span><!--
--><span class='append'><%- ctx.getTagUsages(tag) %></span><!--
--></li><!--
--><% } %><!--
--></ul>

View file

@ -13,6 +13,7 @@ const KEY_RETURN = 13;
const SOURCE_INIT = 'init';
const SOURCE_IMPLICATION = 'implication';
const SOURCE_USER_INPUT = 'user-input';
const SOURCE_SUGGESTION = 'suggestions';
function _fadeOutListItemNodeStatus(listItemNode) {
if (listItemNode.classList.length) {
@ -29,10 +30,55 @@ function _fadeOutListItemNodeStatus(listItemNode) {
}
}
class SuggestionList {
constructor() {
this._suggestions = {};
this._banned = [];
}
clear() {
this._suggestions = {};
}
get length() {
return Object.keys(this._suggestions).length;
}
set(suggestion, weight) {
if (this._suggestions.hasOwnProperty(suggestion)) {
weight = Math.max(weight, this._suggestions[suggestion]);
}
this._suggestions[suggestion] = weight;
}
ban(suggestion) {
this._banned.push(suggestion);
}
getAll() {
let tuples = [];
for (let suggestion of Object.keys(this._suggestions)) {
if (!this._banned.includes(suggestion)) {
const weight = this._suggestions[suggestion];
tuples.push([suggestion, weight.toFixed(1)]);
}
}
tuples.sort((a, b) => {
let weightDiff = b[1] - a[1];
let nameDiff = a[0].localeCompare(b[0]);
return weightDiff == 0 ? nameDiff : weightDiff;
});
return tuples.map(tuple => {
return {tagName: tuple[0], weight: tuple[1]};
});
}
}
class TagInputControl extends events.EventTarget {
constructor(sourceInputNode) {
super();
this.tags = [];
this._suggestions = new SuggestionList();
this._relationsTemplate = views.getTemplate('tag-relations');
this._sourceInputNode = sourceInputNode;
@ -64,6 +110,20 @@ class TagInputControl extends events.EventTarget {
'paste', e => this._evtInputPaste(e));
this._editAreaNode.appendChild(this._tagInputNode);
this._suggestionsNode = views.htmlToDom(
'<div class="tag-suggestions">' +
'<div class="wrapper">' +
'<p>Suggested tags<a class="close" href="#">×</a></p>' +
'<ul></ul>' +
'</div>' +
'</div>');
this._editAreaNode.appendChild(this._suggestionsNode);
this._editAreaNode.querySelector('a.close').addEventListener(
'click', e => {
e.preventDefault();
this._closeSuggestionsPopup();
});
this._tagListNode = views.htmlToDom('<ul class="compact-tags"></ul>');
this._editAreaNode.appendChild(this._tagListNode);
@ -112,7 +172,7 @@ class TagInputControl extends events.EventTarget {
// XXX: perhaps we should aggregate suggestions from all implications
// for call to the _suggestRelations
if ([SOURCE_USER_INPUT, SOURCE_IMPLICATION].includes(source)) {
if (source !== SOURCE_INIT) {
for (let otherTagName of tags.getAllImplications(tagName)) {
this.addTag(otherTagName, SOURCE_IMPLICATION);
}
@ -144,12 +204,14 @@ class TagInputControl extends events.EventTarget {
_evtTagAdded(e) {
const tagName = e.detail.tagName;
const actualTag = tags.getTagByName(tagName);
let listItemNode = this._getListItemNodeFromTagName(tagName);
if (listItemNode) {
const alreadyAdded = !!listItemNode;
if (alreadyAdded) {
listItemNode.classList.add('duplicate');
} else {
listItemNode = this._createListItemNode(tagName);
if (!tags.getTagByName(tagName)) {
if (!actualTag) {
listItemNode.classList.add('new');
}
if (e.detail.source === SOURCE_IMPLICATION) {
@ -158,6 +220,11 @@ class TagInputControl extends events.EventTarget {
this._tagListNode.prependChild(listItemNode);
}
_fadeOutListItemNodeStatus(listItemNode);
if ([SOURCE_USER_INPUT, SOURCE_SUGGESTION].includes(e.detail.source) &&
actualTag) {
this._loadSuggestions(actualTag);
}
}
_evtTagRemoved(e) {
@ -201,43 +268,55 @@ class TagInputControl extends events.EventTarget {
this._hideAutoComplete();
this.addMultipleTags(text, SOURCE_USER_INPUT);
this._tagInputNode.value = '';
// TODO: suggest relations!
}
_createListItemNode(tagName) {
const actualTag = tags.getTagByName(tagName);
const className = actualTag ?
misc.makeCssName(actualTag.category, 'tag') :
'';
null;
if (actualTag) {
tagName = actualTag.names[0];
}
const tagLinkNode = views.htmlToDom(
views.makeNonVoidElement(
'a',
{
class: className,
href: '/tag/' + encodeURIComponent(tagName),
},
'<i class="fa fa-tag"></i>'));
const tagLinkNode = document.createElement('a');
if (className) {
tagLinkNode.classList.add(className);
}
tagLinkNode.setAttribute(
'href', '/tag/' + encodeURIComponent(tagName));
const tagIconNode = document.createElement('i');
tagIconNode.classList.add('fa');
tagIconNode.classList.add('fa-tag');
tagLinkNode.appendChild(tagIconNode);
const searchLinkNode = views.htmlToDom(
views.makeNonVoidElement(
'a',
{
class: className,
href: '/posts/query=' + encodeURIComponent(tagName),
},
actualTag ? actualTag.names[0] : tagName));
const searchLinkNode = document.createElement('a');
if (className) {
searchLinkNode.classList.add(className);
}
searchLinkNode.setAttribute(
'href', '/posts/query=' + encodeURIComponent(tagName));
searchLinkNode.textContent = tagName;
searchLinkNode.addEventListener('click', e => {
e.preventDefault();
if (actualTag) {
this._suggestions.clear();
this._loadSuggestions(actualTag);
} else {
this._closeSuggestionsPopup();
}
});
const usagesNode = views.htmlToDom(
views.makeNonVoidElement(
'span',
{class: 'count'},
{class: 'append'},
actualTag ? actualTag.usages : 0));
const removalLinkNode = views.htmlToDom(
views.makeNonVoidElement(
'a',
{href: '#', class: 'count'},
{href: '#', class: 'append'},
'×'));
removalLinkNode.addEventListener('click', e => {
e.preventDefault();
@ -253,6 +332,95 @@ class TagInputControl extends events.EventTarget {
return listItemNode;
}
_loadSuggestions(tag) {
api.get('/tag-siblings/' + tag.names[0], {noProgress: true})
.then(response => {
return Promise.resolve(response.results);
}, response => {
return Promise.resolve([]);
}).then(siblings => {
let maxSiblingOccurrences = Math.max(
1, ...siblings.map(s => s.occurrences));
for (let sibling of siblings) {
this._suggestions.set(
sibling.tag.names[0],
sibling.occurrences * 4.9 / maxSiblingOccurrences);
}
for (let suggestion of tag.suggestions || []) {
this._suggestions.set(suggestion, 5);
}
if (this._suggestions.length) {
this._openSuggestionsPopup();
} else {
this._closeSuggestionsPopup();
}
});
}
_refreshSuggestionsPopup() {
if (!this._suggestionsNode.classList.contains('shown')) {
return;
}
const listNode = this._suggestionsNode.querySelector('ul');
while (listNode.firstChild) {
listNode.removeChild(listNode.firstChild);
}
for (let tuple of this._suggestions.getAll()) {
const tagName = tuple.tagName;
const weight = tuple.weight;
if (this.isTaggedWith(tagName)) {
continue;
}
const actualTag = tags.getTagByName(tagName);
const addLinkNode = document.createElement('a');
addLinkNode.textContent = tagName;
addLinkNode.setAttribute('href', '#');
addLinkNode.classList.add('add-tag');
if (actualTag) {
addLinkNode.classList.add(
misc.makeCssName(actualTag.category, 'tag'));
}
addLinkNode.addEventListener('click', e => {
e.preventDefault();
listNode.removeChild(listItemNode);
this.addTag(tagName, SOURCE_SUGGESTION);
});
const weightNode = document.createElement('span');
weightNode.classList.add('tag-weight');
weightNode.classList.add('append');
weightNode.textContent = weight;
const removeLinkNode = document.createElement('a');
removeLinkNode.classList.add('remove-tag');
removeLinkNode.classList.add('append');
removeLinkNode.textContent = '×';
removeLinkNode.setAttribute('href', '#');
removeLinkNode.addEventListener('click', e => {
e.preventDefault();
listNode.removeChild(listItemNode);
this._suggestions.ban(tagName);
});
const listItemNode = document.createElement('li');
listItemNode.appendChild(weightNode);
listItemNode.appendChild(addLinkNode);
listItemNode.appendChild(removeLinkNode);
listNode.appendChild(listItemNode);
}
}
_closeSuggestionsPopup() {
this._suggestions.clear();
this._suggestionsNode.classList.remove('shown');
}
_openSuggestionsPopup() {
this._suggestionsNode.classList.add('shown');
this._refreshSuggestionsPopup();
}
_hideAutoComplete() {
this._autoCompleteControl.hide();
}