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
|
$new-tag-text-color = black
|
||||||
$implied-tag-background-color = #FFC
|
$implied-tag-background-color = #FFC
|
||||||
$implied-tag-text-color = black
|
$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-background-color = #FDC
|
||||||
$duplicate-tag-text-color = black
|
$duplicate-tag-text-color = black
|
||||||
$note-overlay-background-color = rgba(255, 255, 255, 0.3)
|
$note-overlay-background-color = rgba(255, 255, 255, 0.3)
|
||||||
|
|
|
@ -174,6 +174,7 @@ input:disabled
|
||||||
cursor: not-allowed
|
cursor: not-allowed
|
||||||
|
|
||||||
div.tag-input
|
div.tag-input
|
||||||
|
position: relative
|
||||||
li
|
li
|
||||||
transition: background-color 0.5s linear
|
transition: background-color 0.5s linear
|
||||||
&.implication
|
&.implication
|
||||||
|
@ -188,6 +189,71 @@ div.tag-input
|
||||||
ul
|
ul
|
||||||
margin-top: 0.2em
|
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
|
ul.compact-tags
|
||||||
line-height: 130%
|
line-height: 130%
|
||||||
word-break: break-all
|
word-break: break-all
|
||||||
|
@ -195,9 +261,9 @@ ul.compact-tags
|
||||||
margin: 0
|
margin: 0
|
||||||
i
|
i
|
||||||
padding-right: 0.4em
|
padding-right: 0.4em
|
||||||
.count
|
.append
|
||||||
color: $inactive-link-color
|
color: $inactive-link-color
|
||||||
padding-left: 0.7em
|
margin-left: 0.7em
|
||||||
font-size: 90%
|
font-size: 90%
|
||||||
|
|
||||||
div.tag-relations
|
div.tag-relations
|
||||||
|
@ -285,7 +351,7 @@ input::-moz-focus-inner
|
||||||
* File dropper
|
* File dropper
|
||||||
*/
|
*/
|
||||||
.file-dropper
|
.file-dropper
|
||||||
background: white
|
background: $window-color
|
||||||
border: 3px dashed #eee
|
border: 3px dashed #eee
|
||||||
padding: 0.3em 0.5em
|
padding: 0.3em 0.5em
|
||||||
line-height: 140%
|
line-height: 140%
|
||||||
|
@ -307,7 +373,7 @@ input[type=file]:focus+.file-dropper,
|
||||||
.autocomplete
|
.autocomplete
|
||||||
position: absolute
|
position: absolute
|
||||||
z-index: 10
|
z-index: 10
|
||||||
background: white
|
background: $window-color
|
||||||
border: 2px solid $main-color
|
border: 2px solid $main-color
|
||||||
display: none
|
display: none
|
||||||
font-size: 0.95em
|
font-size: 0.95em
|
||||||
|
|
|
@ -80,7 +80,7 @@
|
||||||
--><% if (ctx.canListPosts) { %><!--
|
--><% if (ctx.canListPosts) { %><!--
|
||||||
--></a><!--
|
--></a><!--
|
||||||
--><% } %><!--
|
--><% } %><!--
|
||||||
--><span class='count'><%- ctx.getTagUsages(tag) %></span><!--
|
--><span class='append'><%- ctx.getTagUsages(tag) %></span><!--
|
||||||
--></li><!--
|
--></li><!--
|
||||||
--><% } %><!--
|
--><% } %><!--
|
||||||
--></ul>
|
--></ul>
|
||||||
|
|
|
@ -13,6 +13,7 @@ const KEY_RETURN = 13;
|
||||||
const SOURCE_INIT = 'init';
|
const SOURCE_INIT = 'init';
|
||||||
const SOURCE_IMPLICATION = 'implication';
|
const SOURCE_IMPLICATION = 'implication';
|
||||||
const SOURCE_USER_INPUT = 'user-input';
|
const SOURCE_USER_INPUT = 'user-input';
|
||||||
|
const SOURCE_SUGGESTION = 'suggestions';
|
||||||
|
|
||||||
function _fadeOutListItemNodeStatus(listItemNode) {
|
function _fadeOutListItemNodeStatus(listItemNode) {
|
||||||
if (listItemNode.classList.length) {
|
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 {
|
class TagInputControl extends events.EventTarget {
|
||||||
constructor(sourceInputNode) {
|
constructor(sourceInputNode) {
|
||||||
super();
|
super();
|
||||||
this.tags = [];
|
this.tags = [];
|
||||||
|
this._suggestions = new SuggestionList();
|
||||||
|
|
||||||
this._relationsTemplate = views.getTemplate('tag-relations');
|
this._relationsTemplate = views.getTemplate('tag-relations');
|
||||||
this._sourceInputNode = sourceInputNode;
|
this._sourceInputNode = sourceInputNode;
|
||||||
|
@ -64,6 +110,20 @@ class TagInputControl extends events.EventTarget {
|
||||||
'paste', e => this._evtInputPaste(e));
|
'paste', e => this._evtInputPaste(e));
|
||||||
this._editAreaNode.appendChild(this._tagInputNode);
|
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._tagListNode = views.htmlToDom('<ul class="compact-tags"></ul>');
|
||||||
this._editAreaNode.appendChild(this._tagListNode);
|
this._editAreaNode.appendChild(this._tagListNode);
|
||||||
|
|
||||||
|
@ -112,7 +172,7 @@ class TagInputControl extends events.EventTarget {
|
||||||
|
|
||||||
// XXX: perhaps we should aggregate suggestions from all implications
|
// XXX: perhaps we should aggregate suggestions from all implications
|
||||||
// for call to the _suggestRelations
|
// for call to the _suggestRelations
|
||||||
if ([SOURCE_USER_INPUT, SOURCE_IMPLICATION].includes(source)) {
|
if (source !== SOURCE_INIT) {
|
||||||
for (let otherTagName of tags.getAllImplications(tagName)) {
|
for (let otherTagName of tags.getAllImplications(tagName)) {
|
||||||
this.addTag(otherTagName, SOURCE_IMPLICATION);
|
this.addTag(otherTagName, SOURCE_IMPLICATION);
|
||||||
}
|
}
|
||||||
|
@ -144,12 +204,14 @@ class TagInputControl extends events.EventTarget {
|
||||||
|
|
||||||
_evtTagAdded(e) {
|
_evtTagAdded(e) {
|
||||||
const tagName = e.detail.tagName;
|
const tagName = e.detail.tagName;
|
||||||
|
const actualTag = tags.getTagByName(tagName);
|
||||||
let listItemNode = this._getListItemNodeFromTagName(tagName);
|
let listItemNode = this._getListItemNodeFromTagName(tagName);
|
||||||
if (listItemNode) {
|
const alreadyAdded = !!listItemNode;
|
||||||
|
if (alreadyAdded) {
|
||||||
listItemNode.classList.add('duplicate');
|
listItemNode.classList.add('duplicate');
|
||||||
} else {
|
} else {
|
||||||
listItemNode = this._createListItemNode(tagName);
|
listItemNode = this._createListItemNode(tagName);
|
||||||
if (!tags.getTagByName(tagName)) {
|
if (!actualTag) {
|
||||||
listItemNode.classList.add('new');
|
listItemNode.classList.add('new');
|
||||||
}
|
}
|
||||||
if (e.detail.source === SOURCE_IMPLICATION) {
|
if (e.detail.source === SOURCE_IMPLICATION) {
|
||||||
|
@ -158,6 +220,11 @@ class TagInputControl extends events.EventTarget {
|
||||||
this._tagListNode.prependChild(listItemNode);
|
this._tagListNode.prependChild(listItemNode);
|
||||||
}
|
}
|
||||||
_fadeOutListItemNodeStatus(listItemNode);
|
_fadeOutListItemNodeStatus(listItemNode);
|
||||||
|
|
||||||
|
if ([SOURCE_USER_INPUT, SOURCE_SUGGESTION].includes(e.detail.source) &&
|
||||||
|
actualTag) {
|
||||||
|
this._loadSuggestions(actualTag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtTagRemoved(e) {
|
_evtTagRemoved(e) {
|
||||||
|
@ -201,43 +268,55 @@ class TagInputControl extends events.EventTarget {
|
||||||
this._hideAutoComplete();
|
this._hideAutoComplete();
|
||||||
this.addMultipleTags(text, SOURCE_USER_INPUT);
|
this.addMultipleTags(text, SOURCE_USER_INPUT);
|
||||||
this._tagInputNode.value = '';
|
this._tagInputNode.value = '';
|
||||||
// TODO: suggest relations!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_createListItemNode(tagName) {
|
_createListItemNode(tagName) {
|
||||||
const actualTag = tags.getTagByName(tagName);
|
const actualTag = tags.getTagByName(tagName);
|
||||||
const className = actualTag ?
|
const className = actualTag ?
|
||||||
misc.makeCssName(actualTag.category, 'tag') :
|
misc.makeCssName(actualTag.category, 'tag') :
|
||||||
'';
|
null;
|
||||||
|
if (actualTag) {
|
||||||
|
tagName = actualTag.names[0];
|
||||||
|
}
|
||||||
|
|
||||||
const tagLinkNode = views.htmlToDom(
|
const tagLinkNode = document.createElement('a');
|
||||||
views.makeNonVoidElement(
|
if (className) {
|
||||||
'a',
|
tagLinkNode.classList.add(className);
|
||||||
{
|
}
|
||||||
class: className,
|
tagLinkNode.setAttribute(
|
||||||
href: '/tag/' + encodeURIComponent(tagName),
|
'href', '/tag/' + encodeURIComponent(tagName));
|
||||||
},
|
const tagIconNode = document.createElement('i');
|
||||||
'<i class="fa fa-tag"></i>'));
|
tagIconNode.classList.add('fa');
|
||||||
|
tagIconNode.classList.add('fa-tag');
|
||||||
|
tagLinkNode.appendChild(tagIconNode);
|
||||||
|
|
||||||
const searchLinkNode = views.htmlToDom(
|
const searchLinkNode = document.createElement('a');
|
||||||
views.makeNonVoidElement(
|
if (className) {
|
||||||
'a',
|
searchLinkNode.classList.add(className);
|
||||||
{
|
}
|
||||||
class: className,
|
searchLinkNode.setAttribute(
|
||||||
href: '/posts/query=' + encodeURIComponent(tagName),
|
'href', '/posts/query=' + encodeURIComponent(tagName));
|
||||||
},
|
searchLinkNode.textContent = tagName;
|
||||||
actualTag ? actualTag.names[0] : tagName));
|
searchLinkNode.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (actualTag) {
|
||||||
|
this._suggestions.clear();
|
||||||
|
this._loadSuggestions(actualTag);
|
||||||
|
} else {
|
||||||
|
this._closeSuggestionsPopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const usagesNode = views.htmlToDom(
|
const usagesNode = views.htmlToDom(
|
||||||
views.makeNonVoidElement(
|
views.makeNonVoidElement(
|
||||||
'span',
|
'span',
|
||||||
{class: 'count'},
|
{class: 'append'},
|
||||||
actualTag ? actualTag.usages : 0));
|
actualTag ? actualTag.usages : 0));
|
||||||
|
|
||||||
const removalLinkNode = views.htmlToDom(
|
const removalLinkNode = views.htmlToDom(
|
||||||
views.makeNonVoidElement(
|
views.makeNonVoidElement(
|
||||||
'a',
|
'a',
|
||||||
{href: '#', class: 'count'},
|
{href: '#', class: 'append'},
|
||||||
'×'));
|
'×'));
|
||||||
removalLinkNode.addEventListener('click', e => {
|
removalLinkNode.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -253,6 +332,95 @@ class TagInputControl extends events.EventTarget {
|
||||||
return listItemNode;
|
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() {
|
_hideAutoComplete() {
|
||||||
this._autoCompleteControl.hide();
|
this._autoCompleteControl.hide();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue