client/tags: show tag suggestions in tag input
This commit is contained in:
parent
c1c47de3a5
commit
72072db078
4 changed files with 265 additions and 28 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -80,7 +80,7 @@
|
|||
--><% if (ctx.canListPosts) { %><!--
|
||||
--></a><!--
|
||||
--><% } %><!--
|
||||
--><span class='count'><%- ctx.getTagUsages(tag) %></span><!--
|
||||
--><span class='append'><%- ctx.getTagUsages(tag) %></span><!--
|
||||
--></li><!--
|
||||
--><% } %><!--
|
||||
--></ul>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue