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: '{1} ({2})'.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('
'); + 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(''); + } + + _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( + ''); + 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);