aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorrr-2016-05-16 23:01:12 +0200
committerrr-2016-05-18 00:13:17 +0200
commit407848706af00e2b9fa65d5acef552a7bf5e82fb (patch)
treede46f713d954b5b5ef89b414aeeb641444ba5319
parent5736b4adc1c1cdeb4b53c187a9f042eaab7ee1c4 (diff)
views/tags: add tag input control
For now, without tag relations
-rw-r--r--client/css/colors.styl4
-rw-r--r--client/css/forms.styl29
-rw-r--r--client/js/controllers/tags_controller.js4
-rw-r--r--client/js/tags.js60
-rw-r--r--client/js/util/views.js6
-rw-r--r--client/js/views/auto_complete_control.js7
-rw-r--r--client/js/views/tag_auto_complete_control.js4
-rw-r--r--client/js/views/tag_input_control.js387
-rw-r--r--client/js/views/tag_summary_view.js9
9 files changed, 482 insertions, 28 deletions
diff --git a/client/css/colors.styl b/client/css/colors.styl
index 8cdf54f..a7900fc 100644
--- a/client/css/colors.styl
+++ b/client/css/colors.styl
@@ -27,3 +27,7 @@ $button-enabled-text-color = white
$button-enabled-background-color = $main-color
$button-disabled-text-color = #666
$button-disabled-background-color = #CCC
+$new-tag-background-color = #DFC
+$new-tag-text-color = black
+$duplicate-tag-background-color = #FDC
+$duplicate-tag-text-color = black
diff --git a/client/css/forms.styl b/client/css/forms.styl
index 406482e..2a294bc 100644
--- a/client/css/forms.styl
+++ b/client/css/forms.styl
@@ -31,14 +31,18 @@ form
form.tabular
ul
display: table
+ table-layout: fixed
width: 100%
li
display: table-row
label:not(.radio):not(.checkbox):not(.file-dropper)
display: table-cell
width: 33%
+ text-align: right
+ padding-right: 1em
.messages, .buttons
margin-left: 33%
+ padding-left: 1em
form.horizontal
display: inline-block
@@ -146,6 +150,7 @@ input[type=checkbox]:focus + .checkbox:before
/*
* Regular inputs
*/
+div.tag-input,
select,
textarea,
input[type=text],
@@ -177,6 +182,30 @@ input[type=password]
background: $input-disabled-background-color
color: $input-disabled-text-color
+div.tag-input
+ min-height: 3.3em
+ vertical-align: top
+ display: inline-block
+ cursor: text
+ word-wrap: break-word
+ a
+ line-height: 110%
+ position: relative
+ z-index: 1
+ span
+ line-height: 110%
+ &.new a
+ background: $new-tag-background-color
+ color: $new-tag-text-color
+ &.duplicate a
+ background: $duplicate-tag-background-color
+ color: $duplicate-tag-text-color
+ &.editable
+ outline: 0
+ padding-right: 0.2em
+ &:last-child
+ padding-left: 0.2em
+
label.color
position: relative
input[type=text]
diff --git a/client/js/controllers/tags_controller.js b/client/js/controllers/tags_controller.js
index dfead15..c096c16 100644
--- a/client/js/controllers/tags_controller.js
+++ b/client/js/controllers/tags_controller.js
@@ -98,8 +98,8 @@ class TagsController {
_show(tag, section) {
topNavController.activate('tags');
const categories = {};
- for (let [key, category] of tags.getExport().categories) {
- categories[key] = category.name;
+ for (let category of tags.getAllCategories()) {
+ categories[category.name] = category.name;
}
this.tagView.render({
tag: tag,
diff --git a/client/js/tags.js b/client/js/tags.js
index ceaa85a..359d997 100644
--- a/client/js/tags.js
+++ b/client/js/tags.js
@@ -4,14 +4,47 @@ const request = require('superagent');
const util = require('./util/misc.js');
const events = require('./events.js');
-let _export = null;
+let _tags = null;
+let _categories = null;
let _stylesheet = null;
+function getTagByName(name) {
+ return _tags.get(name.toLowerCase());
+}
+
+function getCategoryByName(name) {
+ return _categories.get(name.toLowerCase());
+}
+
+function getNameToTagMap() {
+ return _tags;
+}
+
+function getAllTags() {
+ return _tags.values();
+}
+
+function getAllCategories() {
+ return _categories.values();
+}
+
+function getOriginalTagName(name) {
+ const actualTag = getTagByName(name);
+ if (actualTag) {
+ for (let originalName of actualTag.names) {
+ if (originalName.toLowerCase() == name.toLowerCase()) {
+ return originalName;
+ }
+ }
+ }
+ return name;
+}
+
function _tagsToMap(tags) {
let map = new Map();
for (let tag of tags) {
for (let name of tag.names) {
- map.set(name, tag);
+ map.set(name.toLowerCase(), tag);
}
}
return map;
@@ -20,7 +53,7 @@ function _tagsToMap(tags) {
function _tagCategoriesToMap(categories) {
let map = new Map();
for (let category of categories) {
- map.set(category.name, category);
+ map.set(category.name.toLowerCase(), category);
}
return map;
}
@@ -31,7 +64,7 @@ function _refreshStylesheet() {
}
_stylesheet = document.createElement('style');
document.head.appendChild(_stylesheet);
- for (let category of _export.categories.values()) {
+ for (let category of getAllCategories()) {
_stylesheet.sheet.insertRule(
'.tag-{0} { color: {1} }'.format(category.name, category.color),
_stylesheet.sheet.cssRules.length);
@@ -42,27 +75,28 @@ function refreshExport() {
return new Promise((resolve, reject) => {
request.get('/data/tags.json').end((error, response) => {
if (error) {
- _export = {tags: {}, categories: {}};
+ _tags = new Map();
+ _categories = new Map();
reject(error);
}
- _export = response.body;
- _export.tags = _tagsToMap(_export.tags);
- _export.categories = _tagCategoriesToMap(_export.categories);
+ _tags = _tagsToMap(response.body.tags);
+ _categories = _tagCategoriesToMap(response.body.categories);
_refreshStylesheet();
resolve();
});
});
}
-function getExport() {
- return _export || {};
-}
-
events.listen(
events.TagsChange,
() => { refreshExport(); return true; });
module.exports = {
- getExport: getExport,
+ getAllCategories: getAllCategories,
+ getAllTags: getAllTags,
+ getTagByName: getTagByName,
+ getCategoryByName: getCategoryByName,
+ getNameToTagMap: getNameToTagMap,
+ getOriginalTagName: getOriginalTagName,
refreshExport: refreshExport,
};
diff --git a/client/js/util/views.js b/client/js/util/views.js
index 38de199..2e032ed 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -138,10 +138,9 @@ function makeColorInput(options) {
}
function makeTagLink(name) {
- const tagExport = tags.getExport();
let category = null;
try {
- category = tagExport.tags.get(name).category;
+ category = tags.getTagByName(name).category;
} catch (e) {
category = 'unknown';
}
@@ -321,7 +320,8 @@ function scrollToHash() {
}
document.addEventListener('input', e => {
- if (e.target.getAttribute('type').toLowerCase() === 'color') {
+ const type = e.target.getAttribute('type');
+ if (type && type.toLowerCase() === 'color') {
const textInput = e.target.parentNode.querySelector('input[type=text]');
textInput.style.color = e.target.value;
textInput.value = e.target.value;
diff --git a/client/js/views/auto_complete_control.js b/client/js/views/auto_complete_control.js
index 9416ce1..6c8bc5a 100644
--- a/client/js/views/auto_complete_control.js
+++ b/client/js/views/auto_complete_control.js
@@ -28,6 +28,7 @@ class AutoCompleteControl {
constructor(input, options) {
this.input = input;
this.options = lodash.extend({}, {
+ verticalShift: 2,
source: null,
maxResults: 15,
getTextToFind: () => {
@@ -229,7 +230,7 @@ class AutoCompleteControl {
this.suggestionDiv.style.left = '-9999px';
this.suggestionDiv.style.top = '-9999px';
this.show();
- const borderSize = 2;
+ const verticalShift = this.options.verticalShift;
const inputRect = this.input.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
const viewPortHeight = bodyRect.bottom - bodyRect.top;
@@ -242,8 +243,8 @@ class AutoCompleteControl {
let x = inputRect.left - bodyRect.left;
let y = direction == 1 ?
- inputRect.bottom - bodyRect.top - borderSize :
- inputRect.top - bodyRect.top - listRect.height + borderSize;
+ inputRect.bottom - bodyRect.top - verticalShift :
+ inputRect.top - bodyRect.top - listRect.height + verticalShift;
// remove offscreen items until whole suggestion list can fit on the
// screen
diff --git a/client/js/views/tag_auto_complete_control.js b/client/js/views/tag_auto_complete_control.js
index b58fef9..7bb9090 100644
--- a/client/js/views/tag_auto_complete_control.js
+++ b/client/js/views/tag_auto_complete_control.js
@@ -6,7 +6,7 @@ const AutoCompleteControl = require('./auto_complete_control.js');
class TagAutoCompleteControl extends AutoCompleteControl {
constructor(input, options) {
- const allTags = tags.getExport().tags;
+ const allTags = tags.getNameToTagMap();
const caseSensitive = false;
const minLengthForPartialSearch = 3;
@@ -30,7 +30,7 @@ class TagAutoCompleteControl extends AutoCompleteControl {
caption:
'<span class="tag-{0}">{1} ({2})</span>'.format(
kv[1].category,
- kv[0],
+ tags.getOriginalTagName(kv[0]),
kv[1].usages),
value: kv[0],
};
diff --git a/client/js/views/tag_input_control.js b/client/js/views/tag_input_control.js
new file mode 100644
index 0000000..15498dd
--- /dev/null
+++ b/client/js/views/tag_input_control.js
@@ -0,0 +1,387 @@
+'use strict';
+
+const tags = require('../tags.js');
+const views = require('../util/views.js');
+const TagAutoCompleteControl = require('./tag_auto_complete_control.js');
+
+const KEY_A = 65;
+const KEY_END = 35;
+const KEY_HOME = 36;
+const KEY_LEFT = 37;
+const KEY_RIGHT = 39;
+const KEY_SPACE = 32;
+const KEY_RETURN = 13;
+const KEY_BACKSPACE = 8;
+const KEY_DELETE = 46;
+
+class TagInputControl {
+ constructor(sourceInputNode) {
+ this.tags = [];
+ this.readOnly = sourceInputNode.readOnly;
+
+ this._autoCompleteControls = [];
+
+ this._sourceInputNode = sourceInputNode;
+
+ // set up main edit area
+ this._editAreaNode = views.htmlToDom('<div class="tag-input"></div>');
+ this._editAreaNode.autocorrect = false;
+ this._editAreaNode.autocapitalize = false;
+ this._editAreaNode.spellcheck = false;
+ this._editAreaNode.addEventListener(
+ 'click', e => this._evtEditAreaClick(e));
+
+ // set up tail editor
+ this._tailWrapperNode = this._createWrapper();
+ if (!this.readOnly) {
+ this._tailInputNode = this._createInput();
+ this._tailInputNode.tabIndex = 0;
+ this._tailWrapperNode.appendChild(this._tailInputNode);
+ } else {
+ this._tailInputNode = null;
+ }
+ this._editAreaNode.appendChild(this._tailWrapperNode);
+
+ // add existing tags
+ this.addMultipleTags(sourceInputNode.value);
+
+ // show
+ sourceInputNode.style.display = 'none';
+ sourceInputNode.parentNode.insertBefore(
+ this._editAreaNode, sourceInputNode.nextSibling);
+ }
+
+ addMultipleTags(text, sourceNode) {
+ for (let tag of text.split(/\s+/).filter(word => word)) {
+ this.addTag(tag, sourceNode);
+ }
+ }
+
+ addTag(text, sourceNode) {
+ text = tags.getOriginalTagName(text);
+
+ if (!sourceNode) {
+ sourceNode = this._tailWrapperNode;
+ }
+
+ if (!text) {
+ return;
+ }
+
+ // TODO: add implications
+
+ if (this.tags.map(tag => tag.toLowerCase())
+ .includes(text.toLowerCase())) {
+ this._getWrapperFromTag(text).classList.add('duplicate');
+ return;
+ }
+
+ this._hideVisualCues();
+
+ this.tags.push(text);
+ this._sourceInputNode.value = this.tags.join(' ');
+
+ const sourceWrapperNode = this._getWrapperFromChild(sourceNode);
+ const targetWrapperNode = this._createWrapper();
+ if (!this.readOnly) {
+ targetWrapperNode.appendChild(this._createInput());
+ }
+ if (!tags.getTagByName(text)) {
+ targetWrapperNode.classList.add('new');
+ }
+ targetWrapperNode.appendChild(this._createLink(text));
+ targetWrapperNode.setAttribute('data-tag', text);
+ this._editAreaNode.insertBefore(targetWrapperNode, sourceWrapperNode);
+ this._editAreaNode.insertBefore(this._createSpace(), sourceWrapperNode);
+ }
+
+ deleteTag(tag) {
+ if (!tag) {
+ return;
+ }
+ if (!this.tags.map(tag => tag.toLowerCase())
+ .includes(tag.toLowerCase())) {
+ return;
+ }
+ this._hideVisualCues();
+ this.tags = this.tags.filter(t => t.toLowerCase() != tag.toLowerCase());
+ this._sourceInputNode.value = this.tags.join(' ');
+ for (let wrapperNode of this._getAllWrapperNodes()) {
+ if (this._getTagFromWrapper(wrapperNode).toLowerCase() ==
+ tag.toLowerCase()) {
+ if (wrapperNode.contains(document.activeElement)) {
+ const nextWrapperNode = this._getNextWrapper(wrapperNode);
+ const nextInputNode =
+ nextWrapperNode.querySelector('.editable');
+ if (nextInputNode) {
+ nextInputNode.focus();
+ }
+ }
+ this._editAreaNode.removeChild(wrapperNode);
+ break;
+ }
+ }
+ }
+
+ _evtEditAreaClick(e) {
+ if (e.target.nodeName.toLowerCase() === 'a') {
+ return;
+ }
+
+ if (this.readOnly) {
+ return;
+ }
+
+ e.preventDefault();
+
+ let closestInputNode = null;
+ let closestDistance = Infinity;
+
+ const mouseX = e.clientX;
+ const mouseY = e.clientY;
+
+ for (let wrapperNode of this._getAllWrapperNodes()) {
+ const inputNode = wrapperNode.querySelector('.editable');
+ if (!inputNode) {
+ continue;
+ }
+ const inputNodeRect = inputNode.getBoundingClientRect();
+ const inputNodeX = inputNodeRect.left;
+ const inputNodeY = inputNodeRect.top;
+ const distance = Math.sqrt(
+ Math.pow(mouseX - inputNodeX, 2) +
+ Math.pow(mouseY - inputNodeY, 2));
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestInputNode = inputNode;
+ }
+ }
+
+ if (closestDistance > 25) {
+ closestInputNode = this._tailInputNode;
+ }
+
+ closestInputNode.focus();
+ }
+
+ _evtInputPaste(e) {
+ e.preventDefault();
+ const pastedText = window.clipboardData ?
+ window.clipboardData.getData('Text') :
+ (e.originalEvent || e).clipboardData.getData('text/plain');
+
+ if (pastedText.length > 2000) {
+ window.alert('Pasted text is too long.');
+ return;
+ }
+ this.addMultipleTags(pastedText);
+ }
+
+ _evtInputKeyDown(e) {
+ const inputNode = e.target;
+ const wrapperNode = this._getWrapperFromChild(inputNode);
+ const key = e.which;
+
+ if (key == KEY_A && e.ctrlKey) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ e.stopPropagation();
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNode(this._editAreaNode);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+
+ if (key == KEY_HOME) {
+ if (window.getSelection().getRangeAt(0).startOffset !== 0) {
+ return;
+ }
+ e.preventDefault();
+ this._getAllWrapperNodes()[0].querySelector('.editable').focus();
+ }
+
+ if (key == KEY_END) {
+ if (window.getSelection().getRangeAt(0).endOffset !==
+ inputNode.textContent.length) {
+ return;
+ }
+ e.preventDefault();
+ this._getAllWrapperNodes()[this._getAllWrapperNodes().length - 1]
+ .querySelector('.editable').focus();
+ }
+
+ if (key == KEY_LEFT) {
+ if (window.getSelection().getRangeAt(0).startOffset !== 0) {
+ return;
+ }
+ e.preventDefault();
+ const prevWrapperNode = this._getPreviousWrapper(wrapperNode);
+ if (prevWrapperNode) {
+ prevWrapperNode.querySelector('.editable').focus();
+ }
+ }
+
+ if (key == KEY_RIGHT) {
+ if (window.getSelection().getRangeAt(0).endOffset !==
+ inputNode.textContent.length) {
+ return;
+ }
+ e.preventDefault();
+ const nextWrapperNode = this._getNextWrapper(wrapperNode);
+ if (nextWrapperNode) {
+ nextWrapperNode.querySelector('.editable').focus();
+ }
+ }
+
+ if (key == KEY_BACKSPACE) {
+ if (inputNode.textContent !== '') {
+ return;
+ }
+ e.preventDefault();
+ const prevWrapperNode = this._getPreviousWrapper(wrapperNode);
+ this.deleteTag(this._getTagFromWrapper(prevWrapperNode));
+ }
+
+ if (key == KEY_DELETE) {
+ if (inputNode.textContent !== '') {
+ return;
+ }
+ e.preventDefault();
+ if (!wrapperNode.contains(this._mainEditNode)) {
+ this.deleteTag(this._getTagFromWrapper(wrapperNode));
+ }
+ }
+
+ if (key == KEY_RETURN || key == KEY_SPACE) {
+ e.preventDefault();
+ this.addTag(inputNode.textContent, inputNode);
+ inputNode.innerHTML = '';
+ }
+ }
+
+ _evtInputBlur(e) {
+ const inputNode = e.target;
+ this.addTag(inputNode.textContent, inputNode);
+ inputNode.innerHTML = '';
+ }
+
+ _evtLinkClick(e) {
+ e.preventDefault();
+ // TODO: show suggestions and siblings
+ }
+
+ _getWrapperFromChild(startNode) {
+ let node = startNode;
+ while (node) {
+ if ('classList' in node && node.classList.contains('wrapper')) {
+ return node;
+ }
+ node = node.parentNode;
+ }
+ throw Error('Wrapper node not found');
+ }
+
+ _getPreviousWrapper(wrapperNode) {
+ let result = wrapperNode.previousSibling;
+ while (result && result.nodeType === 3) {
+ result = result.previousSibling;
+ }
+ return result;
+ }
+
+ _getNextWrapper(wrapperNode) {
+ let result = wrapperNode.nextSibling;
+ while (result && result.nodeType === 3) {
+ result = result.nextSibling;
+ }
+ return result;
+ }
+
+ _getAllWrapperNodes() {
+ const result = [];
+ for (let child of this._editAreaNode.childNodes) {
+ if (child.nodeType === 3) {
+ continue;
+ }
+ result.push(child);
+ }
+ return result;
+ }
+
+ _getTagFromWrapper(wrapperNode) {
+ if (!wrapperNode || !wrapperNode.hasAttribute('data-tag')) {
+ return null;
+ }
+ return wrapperNode.getAttribute('data-tag');
+ }
+
+ _getWrapperFromTag(tag) {
+ for (let wrapperNode of this._getAllWrapperNodes()) {
+ if (this._getTagFromWrapper(wrapperNode).toLowerCase() ==
+ tag.toLowerCase()) {
+ return wrapperNode;
+ }
+ }
+ return null;
+ }
+
+ _createWrapper() {
+ return views.htmlToDom('<span class="wrapper"></span>');
+ }
+
+ _createSpace(text) {
+ // space between elements serves two purposes:
+ // - the wrappers play nicely with word-wrap: break-word
+ // - copying the input text to clipboard shows spaces
+ return document.createTextNode(' ');
+ }
+
+ _createInput(text) {
+ const inputNode = views.htmlToDom(
+ '<span class="editable" contenteditable>');
+ const autoCompleteControl = new TagAutoCompleteControl(
+ inputNode, {
+ getTextToFind: () => {
+ return inputNode.textContent;
+ },
+ confirm: text => {
+ const wrapperNode = this._getWrapperFromChild(inputNode);
+ inputNode.innerHTML = '';
+ this.addTag(text, inputNode);
+ },
+ verticalShift: -2,
+ });
+ inputNode.addEventListener('keydown', e => this._evtInputKeyDown(e));
+ inputNode.addEventListener('paste', e => this._evtInputPaste(e));
+ inputNode.addEventListener('blur', e => this._evtInputBlur(e));
+ this._autoCompleteControls.push(autoCompleteControl);
+ return inputNode;
+ }
+
+ _createLink(text) {
+ const actualTag = tags.getTagByName(text);
+ const link = views.htmlToDom(
+ views.makeNonVoidElement(
+ 'a',
+ {
+ class: actualTag ? 'tag-' + actualTag.category : '',
+ href: '/tag/' + text,
+ },
+ text));
+ link.addEventListener('click', e=> this._evtLinkClick(e));
+ return link;
+ }
+
+ _hideVisualCues() {
+ for (let wrapperNode of this._getAllWrapperNodes()) {
+ wrapperNode.classList.remove('duplicate');
+ }
+ for (let autoCompleteControl of this._autoCompleteControls) {
+ autoCompleteControl.hide();
+ }
+ // TODO: hide suggestions and siblings
+ }
+}
+
+module.exports = TagInputControl;
diff --git a/client/js/views/tag_summary_view.js b/client/js/views/tag_summary_view.js
index 3300c4a..8b5b19d 100644
--- a/client/js/views/tag_summary_view.js
+++ b/client/js/views/tag_summary_view.js
@@ -2,7 +2,7 @@
const config = require('../config.js');
const views = require('../util/views.js');
-const TagAutoCompleteControl = require('./tag_auto_complete_control.js');
+const TagInputControl = require('./tag_input_control.js');
function split(str) {
return str.split(/\s+/).filter(s => s);
@@ -23,15 +23,14 @@ class TagSummaryView {
const form = source.querySelector('form');
const namesField = source.querySelector('.names input');
const categoryField = source.querySelector('.category select');
- const implicationsField =
- source.querySelector('.implications input');
+ const implicationsField = source.querySelector('.implications input');
const suggestionsField = source.querySelector('.suggestions input');
if (implicationsField) {
- new TagAutoCompleteControl(implicationsField);
+ new TagInputControl(implicationsField);
}
if (suggestionsField) {
- new TagAutoCompleteControl(suggestionsField);
+ new TagInputControl(suggestionsField);
}
views.decorateValidator(form);

© 2015 - 2026 Jakob L. Kreuze