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(); }