client/tags: add suggesting related tags
This commit is contained in:
parent
fa4412ef90
commit
7ea4718b1b
4 changed files with 165 additions and 18 deletions
|
@ -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]
|
||||
|
|
16
client/html/tag_relations.tpl
Normal file
16
client/html/tag_relations.tpl
Normal file
|
@ -0,0 +1,16 @@
|
|||
<div class='tag-relations'>
|
||||
<% if (ctx.suggestions.length) { %>
|
||||
<ul class='tag-suggestions'>
|
||||
<% _.each(ctx.suggestions.slice(0, 20), tagName => { %>
|
||||
<li><%= ctx.makeTagLink(tagName) %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
<% if (ctx.siblings.length) { %>
|
||||
<ul class='tag-siblings'>
|
||||
<% _.each(ctx.siblings.slice(0, 20), tagName => { %>
|
||||
<li><%= ctx.makeTagLink(tagName) %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
|
@ -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('<span class="wrapper"></span>');
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue