diff --git a/client/css/colors.styl b/client/css/colors.styl
index b2aabc9a..da5f72ed 100644
--- a/client/css/colors.styl
+++ b/client/css/colors.styl
@@ -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)
diff --git a/client/css/forms.styl b/client/css/forms.styl
index 2f8c6e49..bb4c96fc 100644
--- a/client/css/forms.styl
+++ b/client/css/forms.styl
@@ -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
diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl
index 0492b7e0..af3bf3e8 100644
--- a/client/html/post_readonly_sidebar.tpl
+++ b/client/html/post_readonly_sidebar.tpl
@@ -80,7 +80,7 @@
--><% if (ctx.canListPosts) { %><% } %><%- ctx.getTagUsages(tag) %><%- ctx.getTagUsages(tag) %><% } %>
diff --git a/client/js/controls/tag_input_control.js b/client/js/controls/tag_input_control.js
index 1c06792e..7abc5d6d 100644
--- a/client/js/controls/tag_input_control.js
+++ b/client/js/controls/tag_input_control.js
@@ -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(
+ '
' +
+ '
' +
+ '
Suggested tags×
' +
+ '
' +
+ '
' +
+ '
');
+ this._editAreaNode.appendChild(this._suggestionsNode);
+ this._editAreaNode.querySelector('a.close').addEventListener(
+ 'click', e => {
+ e.preventDefault();
+ this._closeSuggestionsPopup();
+ });
+
this._tagListNode = views.htmlToDom('');
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),
- },
- ''));
+ 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();
}