From 7ea4718b1bfcac0a5c8fc29f852194e73a5bc91d Mon Sep 17 00:00:00 2001 From: rr- Date: Sun, 22 May 2016 12:35:16 +0200 Subject: [PATCH] client/tags: add suggesting related tags --- client/css/forms.styl | 22 +++++ client/html/tag_relations.tpl | 16 ++++ client/js/controls/tag_input_control.js | 111 ++++++++++++++++++++---- client/js/util/views.js | 34 ++++++++ 4 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 client/html/tag_relations.tpl diff --git a/client/css/forms.styl b/client/css/forms.styl index 7a2a6f62..62c2119c 100644 --- a/client/css/forms.styl +++ b/client/css/forms.styl @@ -206,6 +206,28 @@ div.tag-input &:last-child padding-left: 0.2em +div.tag-relations + font-size: 80% + ul + display: inline + margin: 0 + padding: 0 + &.tag-siblings li:first-child:before + content: 'Siblings: ' + &.tag-suggestions li:first-child:before + content: 'Suggestions: ' + &:before, &:after + height: 0.25em + content: ' ' + display: block + li + display: inline + margin: 0 + padding: 0 + a + display: inline-block + margin: 0 0 0 1em + label.color position: relative input[type=text] diff --git a/client/html/tag_relations.tpl b/client/html/tag_relations.tpl new file mode 100644 index 00000000..39c09d46 --- /dev/null +++ b/client/html/tag_relations.tpl @@ -0,0 +1,16 @@ +
+ <% if (ctx.suggestions.length) { %> + + <% } %> + <% if (ctx.siblings.length) { %> + + <% } %> +
diff --git a/client/js/controls/tag_input_control.js b/client/js/controls/tag_input_control.js index 2412879d..9edde1ea 100644 --- a/client/js/controls/tag_input_control.js +++ b/client/js/controls/tag_input_control.js @@ -1,5 +1,6 @@ 'use strict'; +const api = require('../api.js'); const tags = require('../tags.js'); const views = require('../util/views.js'); const TagAutoCompleteControl = require('./tag_auto_complete_control.js'); @@ -19,6 +20,8 @@ class TagInputControl { this.tags = []; this.readOnly = sourceInputNode.readOnly; + this._relationsTemplate = views.getTemplate('tag-relations'); + this._relationsNodes = []; this._autoCompleteControls = []; this._sourceInputNode = sourceInputNode; @@ -46,7 +49,7 @@ class TagInputControl { this._editAreaNode.appendChild(this._tailWrapperNode); // add existing tags - this.addMultipleTags(this._sourceInputNode.value); + this.addMultipleTags(this._sourceInputNode.value, false); // show this._sourceInputNode.style.display = 'none'; @@ -54,13 +57,19 @@ class TagInputControl { this._editAreaNode, this._sourceInputNode.nextSibling); } - addMultipleTags(text, sourceNode) { + addMultipleTags(text, sourceNode, addImplications) { for (let tag of text.split(/\s+/).filter(word => word)) { - this.addTag(tag, sourceNode); + this.addTag(tag, sourceNode, addImplications, false); } } - addTag(text, sourceNode) { + isTaggedWith(tag) { + return this.tags + .map(t => t.toLowerCase()) + .includes(tag.toLowerCase()); + } + + addTag(text, sourceNode, addImplications, suggestRelations) { text = tags.getOriginalTagName(text); if (!sourceNode) { @@ -95,12 +104,19 @@ class TagInputControl { this._editAreaNode.insertBefore(targetWrapperNode, sourceWrapperNode); this._editAreaNode.insertBefore(this._createSpace(), sourceWrapperNode); - const actualTag = tags.getTagByName(text); - if (actualTag) { + const actualTag = tags.getTagByName(text) || {}; + + // XXX: perhaps we should aggregate suggestions from all implications + // for call to the _suggestRelations + if (addImplications) { for (let otherTag of (actualTag.implications || [])) { - this.addTag(otherTag, sourceNode); + this.addTag(otherTag, sourceNode, true, false); } } + + if (suggestRelations) { + this._suggestRelations([], actualTag.suggestions || []); + } } deleteTag(tag) { @@ -111,7 +127,7 @@ class TagInputControl { .includes(tag.toLowerCase())) { return; } - this._hideVisualCues(); + this._hideAutoComplete(); this.tags = this.tags.filter(t => t.toLowerCase() != tag.toLowerCase()); this._sourceInputNode.value = this.tags.join(' '); for (let wrapperNode of this._getAllWrapperNodes()) { @@ -263,20 +279,40 @@ class TagInputControl { if (key == KEY_RETURN || key == KEY_SPACE) { e.preventDefault(); - this.addTag(inputNode.textContent, inputNode); + this.addTag(inputNode.textContent, inputNode, true, true); inputNode.innerHTML = ''; } } _evtInputBlur(e) { const inputNode = e.target; - this.addTag(inputNode.textContent, inputNode); + this.addTag(inputNode.textContent, inputNode, true, true); inputNode.innerHTML = ''; } - _evtLinkClick(e) { + _evtTagLinkClick(e) { e.preventDefault(); - // TODO: show suggestions and siblings + const wrapperNode = this._getWrapperFromChild(e.target); + const tagName = this._getTagFromWrapper(wrapperNode); + const actualTag = tags.getTagByName(tagName); + if (!actualTag) { + return; + } + api.get('/tag-siblings/' + tagName, {noProgress: true}) + .then(response => { + return Promise.resolve(response.results); + }, response => { + return Promise.resolve([]); + }).then(siblings => { + const suggestionNames = actualTag.suggestions || []; + const siblingNames = siblings.map(s => s.tag.names[0]); + this._suggestRelations(siblingNames, suggestionNames); + }); + } + + _evtRelationSuggestionLinkClick(e) { + e.preventDefault(); + this.addTag(e.target.textContent, null, true, true); } _getWrapperFromChild(startNode) { @@ -334,6 +370,34 @@ class TagInputControl { return null; } + _suggestRelations(siblingNames, suggestionNames) { + this._hideRelationSuggestions(); + siblingNames = siblingNames + .filter(tag => !this.isTaggedWith(tag)); + suggestionNames = suggestionNames + .filter(tag => !this.isTaggedWith(tag)); + + if (!siblingNames.length && !suggestionNames.length) { + return; + } + + const node = this._relationsTemplate({ + siblings: siblingNames, + suggestions: suggestionNames, + }); + + for (let link of node.querySelectorAll('a')) { + link.addEventListener( + 'click', e => this._evtRelationSuggestionLinkClick(e)); + } + + // TODO: slide down + this._editAreaNode.parentNode.insertBefore( + node, this._editAreaNode.nextSibling); + views.slideDown(node); + this._relationsNodes.push(node); + } + _createWrapper() { return views.htmlToDom(''); } @@ -356,7 +420,7 @@ class TagInputControl { confirm: text => { const wrapperNode = this._getWrapperFromChild(inputNode); inputNode.innerHTML = ''; - this.addTag(text, inputNode); + this.addTag(text, inputNode, true, true); }, verticalShift: -2, }); @@ -377,18 +441,29 @@ class TagInputControl { href: '/tag/' + text, }, text)); - link.addEventListener('click', e=> this._evtLinkClick(e)); + link.addEventListener('click', e=> this._evtTagLinkClick(e)); return link; } + _hideRelationSuggestions() { + while (this._relationsNodes.length) { + const node = this._relationsNodes.pop(0); + views.slideUp(node).then(() => node.parentNode.removeChild(node)); + } + } + + _hideAutoComplete() { + for (let autoCompleteControl of this._autoCompleteControls) { + autoCompleteControl.hide(); + } + } + _hideVisualCues() { for (let wrapperNode of this._getAllWrapperNodes()) { wrapperNode.classList.remove('duplicate'); } - for (let autoCompleteControl of this._autoCompleteControls) { - autoCompleteControl.hide(); - } - // TODO: hide suggestions and siblings + this._hideAutoComplete(); + this._hideRelationSuggestions(); } } diff --git a/client/js/util/views.js b/client/js/util/views.js index 2e27d149..844cb48a 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -317,6 +317,38 @@ function scrollToHash() { }, 10); } +function slideDown(element) { + const duration = 500; + return new Promise((resolve, reject) => { + const height = element.getBoundingClientRect().height; + element.style.maxHeight = '0'; + element.style.overflow = 'hidden'; + window.setTimeout(() => { + element.style.transition = `all ${duration}ms ease`; + element.style.maxHeight = `${height}px`; + }, 50); + window.setTimeout(() => { + resolve(); + }, duration); + }); +} + +function slideUp(element) { + const duration = 500; + return new Promise((resolve, reject) => { + const height = element.getBoundingClientRect().height; + element.style.overflow = 'hidden'; + element.style.maxHeight = `${height}px`; + element.style.transition = `all ${duration}ms ease`; + window.setTimeout(() => { + element.style.maxHeight = 0; + }, 10); + window.setTimeout(() => { + resolve(); + }, duration); + }); +} + document.addEventListener('input', e => { const type = e.target.getAttribute('type'); if (type && type.toLowerCase() === 'color') { @@ -339,4 +371,6 @@ module.exports = { makeVoidElement: makeVoidElement, makeNonVoidElement: makeNonVoidElement, scrollToHash: scrollToHash, + slideDown: slideDown, + slideUp: slideUp, };