diff --git a/sass/_emoji.scss b/sass/_emoji.scss index c890fa6f9..55fd406f3 100644 --- a/sass/_emoji.scss +++ b/sass/_emoji.scss @@ -75,9 +75,13 @@ position: relative; &.insert-emoji { margin: 0; - height: 32px; + padding: 3px; + height: 30px; width: 32px; + &.selected { + background-color: var(--highlight-color); + } &.picked { background-color: var(--highlight-color); } @@ -112,6 +116,9 @@ border: 1px var(--chat-head-color) solid; border-bottom: none; } + &.selected { + background-color: var(--highlight-color); + } padding: 0.25em; font-size: var(--font-size-huge); &:hover { diff --git a/sass/_variables.scss b/sass/_variables.scss index fec17a9d2..ce18d5c22 100644 --- a/sass/_variables.scss +++ b/sass/_variables.scss @@ -62,7 +62,7 @@ $mobile_portrait_length: 480px !default; --chat-topic-display: block; --chat-info-display: block; - --highlight-color: #DCF9F6; + --highlight-color: #B0E8E2; --primary-color: var(--light-blue); --primary-color-dark: #397491; diff --git a/spec/emojis.js b/spec/emojis.js index 2b15afd07..7fa3d951c 100644 --- a/spec/emojis.js +++ b/spec/emojis.js @@ -74,7 +74,7 @@ // Check that ENTER now inserts the match const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input}); - view.emoji_picker_view.onKeyDown(enter_event); + view.emoji_picker_view._onGlobalKeyDown(enter_event); expect(input.value).toBe(''); expect(textarea.value).toBe(':grimacing: '); @@ -136,7 +136,7 @@ // Check that pressing enter without an unambiguous match does nothing const enter_event = Object.assign({}, event, {'keyCode': 13}); - view.emoji_picker_view.onKeyDown(enter_event); + view.emoji_picker_view._onGlobalKeyDown(enter_event); expect(input.value).toBe('smiley'); // Test that TAB autocompletes the to first match @@ -148,7 +148,7 @@ expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); // Check that ENTER now inserts the match - view.emoji_picker_view.onKeyDown(enter_event); + view.emoji_picker_view._onGlobalKeyDown(enter_event); expect(input.value).toBe(''); expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: '); done(); diff --git a/src/converse-autocomplete.js b/src/converse-autocomplete.js index 4ef93537e..0257ce5a7 100644 --- a/src/converse-autocomplete.js +++ b/src/converse-autocomplete.js @@ -282,28 +282,28 @@ converse.plugins.add("converse-autocomplete", { onKeyDown (ev) { if (this.opened) { - if (_.includes([_converse.keycodes.ENTER, _converse.keycodes.TAB], ev.keyCode) && this.selected) { + if (_.includes([converse.keycodes.ENTER, converse.keycodes.TAB], ev.keyCode) && this.selected) { ev.preventDefault(); ev.stopPropagation(); this.select(); return true; - } else if (ev.keyCode === _converse.keycodes.ESCAPE) { + } else if (ev.keyCode === converse.keycodes.ESCAPE) { this.close({'reason': 'esc'}); return true; - } else if (_.includes([_converse.keycodes.UP_ARROW, _converse.keycodes.DOWN_ARROW], ev.keyCode)) { + } else if (_.includes([converse.keycodes.UP_ARROW, converse.keycodes.DOWN_ARROW], ev.keyCode)) { ev.preventDefault(); ev.stopPropagation(); - this[ev.keyCode === _converse.keycodes.UP_ARROW ? "previous" : "next"](); + this[ev.keyCode === converse.keycodes.UP_ARROW ? "previous" : "next"](); return true; } } if (_.includes([ - _converse.keycodes.SHIFT, - _converse.keycodes.META, - _converse.keycodes.META_RIGHT, - _converse.keycodes.ESCAPE, - _converse.keycodes.ALT] + converse.keycodes.SHIFT, + converse.keycodes.META, + converse.keycodes.META_RIGHT, + converse.keycodes.ESCAPE, + converse.keycodes.ALT] , ev.keyCode)) { return; } @@ -323,8 +323,8 @@ converse.plugins.add("converse-autocomplete", { evaluate (ev) { const selecting = this.selected && ev && ( - ev.keyCode === _converse.keycodes.UP_ARROW || - ev.keyCode === _converse.keycodes.DOWN_ARROW + ev.keyCode === converse.keycodes.UP_ARROW || + ev.keyCode === converse.keycodes.DOWN_ARROW ); if (!this.auto_evaluate && !this.auto_completing || selecting) { diff --git a/src/converse-chatview.js b/src/converse-chatview.js index 0a7aab08a..049fe5773 100644 --- a/src/converse-chatview.js +++ b/src/converse-chatview.js @@ -869,29 +869,29 @@ converse.plugins.add('converse-chatview', { return; } if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { - if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) { + if (ev.keyCode === converse.keycodes.FORWARD_SLASH) { // Forward slash is used to run commands. Nothing to do here. return; - } else if (ev.keyCode === _converse.keycodes.ESCAPE) { + } else if (ev.keyCode === converse.keycodes.ESCAPE) { return this.onEscapePressed(ev); - } else if (ev.keyCode === _converse.keycodes.ENTER) { + } else if (ev.keyCode === converse.keycodes.ENTER) { return this.onEnterPressed(ev); - } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { + } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { const textarea = this.el.querySelector('.chat-textarea'); if (!textarea.value || u.hasClass('correcting', textarea)) { return this.editEarlierMessage(); } - } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW && + } else if (ev.keyCode === converse.keycodes.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length && u.hasClass('correcting', this.el.querySelector('.chat-textarea'))) { return this.editLaterMessage(); } } - if ([_converse.keycodes.SHIFT, - _converse.keycodes.META, - _converse.keycodes.META_RIGHT, - _converse.keycodes.ESCAPE, - _converse.keycodes.ALT].includes(ev.keyCode)) { + if ([converse.keycodes.SHIFT, + converse.keycodes.META, + converse.keycodes.META_RIGHT, + converse.keycodes.ESCAPE, + converse.keycodes.ALT].includes(ev.keyCode)) { return; } if (this.model.get('chat_state') !== _converse.COMPOSING) { diff --git a/src/converse-emoji-views.js b/src/converse-emoji-views.js index 38b0bb90f..2b10a2883 100644 --- a/src/converse-emoji-views.js +++ b/src/converse-emoji-views.js @@ -1,15 +1,12 @@ -// Converse.js -// https://conversejs.org -// -// Copyright (c) 2013-2019, the Converse.js developers -// Licensed under the Mozilla Public License (MPLv2) - /** * @module converse-emoji-views + * @copyright 2013-2019, the Converse.js developers + * @license Mozilla Public License (MPLv2) */ import "@converse/headless/converse-emoji"; -import { debounce, find } from "lodash"; +import { debounce, find, get } from "lodash"; +import DOMNavigator from "./dom-navigator"; import bootstrap from "bootstrap.native"; import tpl_emoji_button from "templates/emoji_button.html"; import tpl_emojis from "templates/emojis.html"; @@ -46,8 +43,7 @@ converse.plugins.add('converse-emoji-views', { }, onKeyDown (ev) { - const { _converse } = this.__super__; - if (ev.keyCode === _converse.keycodes.TAB) { + if (ev.keyCode === converse.keycodes.TAB) { const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g); if (value.startsWith(':')) { ev.preventDefault(); @@ -95,6 +91,10 @@ converse.plugins.add('converse-emoji-views', { }, createEmojiPicker () { + if (this.emoji_picker_view) { + this.insertEmojiPicker(); + return; + } if (!_converse.emojipicker) { const id = `converse.emoji-${_converse.bare_jid}`; _converse.emojipicker = new _converse.EmojiPicker({'id': id}); @@ -134,18 +134,28 @@ converse.plugins.add('converse-emoji-views', { _converse.EmojiPickerView = Backbone.VDOMView.extend({ className: 'emoji-picker', events: { - 'click .emoji-picker__header li.emoji-category': 'chooseCategory', + 'click .emoji-picker__header li.emoji-category .pick-category': 'chooseCategory', 'click .emoji-skintone-picker li.emoji-skintone': 'chooseSkinTone', 'click .insert-emoji': 'insertEmoji', + 'focus .emoji-search': 'disableArrowNavigation', 'keydown .emoji-search': 'onKeyDown' }, async initialize () { + this.onGlobalKeyDown = ev => this._onGlobalKeyDown(ev); + document.addEventListener('keydown', this.onGlobalKeyDown); + this.search_results = []; this.debouncedFilter = debounce(input => this.filter(input.value), 150); this.listenTo(this.model, 'change:query', this.render) this.listenTo(this.model, 'change:current_skintone', this.render) - this.listenTo(this.model, 'change:current_category', this.render) + this.listenTo(this.model, 'change:current_category', () => { + this.render(); + const category = this.model.get('current_category'); + const el = this.el.querySelector(`.emoji-category[data-category="${category}"]`); + this.navigator.select(el); + !this.navigator.enabled && this.navigator.enable(); + }); await _converse.api.waitUntil('emojisInitialized'); this.render(); }, @@ -169,11 +179,45 @@ converse.plugins.add('converse-emoji-views', { ); }, + remove () { + document.removeEventListener('keydown', this.onGlobalKeyDown); + Backbone.VDOMView.prototype.remove.call(this); + }, + afterRender () { this.initIntersectionObserver(); const textarea = this.el.querySelector('.emoji-search'); textarea.addEventListener('focus', ev => this.chatview.emitFocused(ev)); textarea.addEventListener('blur', ev => this.chatview.emitBlurred(ev)); + this.initArrowNavigation(); + }, + + initArrowNavigation () { + if (!this.navigator) { + const default_selector = 'li:not(.hidden):not(.emoji-skintone), .emoji-search'; + const options = { + 'jump_to_picked': '.emoji-category', + 'jump_to_picked_selector': '.emoji-category.picked', + 'jump_to_picked_direction': DOMNavigator.DIRECTION.down, + 'picked_selector': '.picked', + 'scroll_container': this.el.querySelector('.emoji-picker__lists'), + 'getSelector': direction => { + if (direction === DOMNavigator.DIRECTION.down) { + const c = this.navigator.selected && this.navigator.selected.getAttribute('data-category'); + return c ? `ul[data-category="${c}"] li:not(.hidden):not(.emoji-skintone), .emoji-search` : default_selector; + } else { + return default_selector; + } + }, + 'onSelected': el => el.matches('.insert-emoji') && this.setCategoryForElement(el.parentElement) + }; + this.navigator = new DOMNavigator(this.el, options); + this.listenTo(this.chatview.model, 'destroy', () => this.navigator.destroy()); + } + }, + + disableArrowNavigation () { + this.navigator.disable(); }, filter (value, set_property) { @@ -196,26 +240,36 @@ converse.plugins.add('converse-emoji-views', { } }, - setCategoryOnVisibilityChange (ev) { - const current = ev.filter(e => e.isIntersecting).pop(); - if (current) { - const category = current.target.getAttribute('data-category'); - const old_category = this.model.get('current_category'); - if (old_category !== category) { - // XXX: Manually set the classes, it's quicker than using the VDOM - this.model.set( - {'current_category': category}, - {'silent': true} - ); - const categories = sizzle('.emoji-picker__header .emoji-category', this.el); - const new_el = categories.filter(el => el.getAttribute('data-category') === category).pop(); - const old_el = categories.filter(el => el.getAttribute('data-category') === old_category).pop(); - new_el && u.addClass('picked', new_el); - old_el && u.removeClass('picked', old_el); - } + setCategoryForElement (el) { + const category = el.getAttribute('data-category'); + const old_category = this.model.get('current_category'); + if (old_category !== category) { + // XXX: Manually set the classes, it's quicker than using the VDOM + this.model.set( + {'current_category': category}, + {'silent': true} + ); + const category_els = sizzle('.emoji-picker__header .emoji-category', this.el); + category_els.forEach(el => u.removeClass('picked', el)); + const new_el = category_els.filter(el => el.getAttribute('data-category') === category).pop(); + new_el && u.addClass('picked', new_el); } }, + setCategoryOnVisibilityChange (ev) { + const selected = this.navigator.selected; + const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop(); + let current; + // Choose the intersection that contains the currently selected + // element, or otherwise the one with the largest ratio. + if (intersection_with_selected) { + current = intersection_with_selected; + } else { + current = ev.reduce((p, c) => c.intersectionRatio >= get(p, 'intersectionRatio', 0) ? c : p, null); + } + current && current.isIntersecting && this.setCategoryForElement(current.target); + }, + initIntersectionObserver () { if (!window.IntersectionObserver) { return; @@ -225,10 +279,9 @@ converse.plugins.add('converse-emoji-views', { } else { const options = { root: this.el.querySelector('.emoji-picker__lists'), - rootMargin: '0px', - threshold: [0.1, 0.2, 0.3, 0.4, 0.5] + threshold: [0.1] } - const handler = debounce((ev) => this.setCategoryOnVisibilityChange(ev), 200); + const handler = ev => this.setCategoryOnVisibilityChange(ev); this.observer = new IntersectionObserver(handler, options); } sizzle('.emoji-picker', this.el).forEach(a => this.observer.observe(a)); @@ -243,24 +296,62 @@ converse.plugins.add('converse-emoji-views', { this.chatview.emoji_dropdown.toggle(); } this.filter('', true); + this.disableArrowNavigation(); + }, + + _onGlobalKeyDown (ev) { + if (ev.keyCode === converse.keycodes.ENTER) { + if (ev.target.matches('.emoji-search') || ( + ev.target.matches('body') && + u.isVisible(this.el) && + this.navigator.selected + )) { + ev.preventDefault(); + ev.stopPropagation(); + if (_converse.emoji_shortnames.includes(ev.target.value)) { + this.insertIntoTextArea(ev.target.value); + } else if (this.search_results.length === 1) { + this.insertIntoTextArea(this.search_results[0].sn); + } else if (this.navigator.selected && this.navigator.selected.matches('.insert-emoji')) { + this.insertIntoTextArea(this.navigator.selected.getAttribute('data-emoji')); + } else if (this.navigator.selected && this.navigator.selected.matches('.emoji-category')) { + this.chooseCategory({'target': this.navigator.selected}); + } + } + } else if (ev.keyCode === converse.keycodes.DOWN_ARROW) { + if (ev.target.matches('.emoji-search') || ( + !this.navigator.enabled && + (ev.target.matches('.pick-category') || ev.target.matches('body')) && + u.isVisible(this.el) + )) { + ev.preventDefault(); + ev.stopPropagation(); + ev.target.blur(); + const category = this.model.get('current_category'); + // If there's no category, we're viewing search results. + const selector = category ? `ul[data-category="${category}"]` : 'ul'; + this.disableArrowNavigation(); + this.navigator.enable(); + this.navigator.handleKeydown(ev); + } + } }, onKeyDown (ev) { - if (ev.keyCode === _converse.keycodes.TAB) { - ev.preventDefault(); - const match = find(_converse.emoji_shortnames, sn => _converse.FILTER_CONTAINS(sn, ev.target.value)); - if (match) { - this.filter(match, true); - } - } else if (ev.keyCode === _converse.keycodes.ENTER) { + if (ev.keyCode === converse.keycodes.RIGHT_ARROW) { ev.preventDefault(); ev.stopPropagation(); - if (_converse.emoji_shortnames.includes(ev.target.value)) { - this.insertIntoTextArea(ev.target.value); - } else if (this.search_results.length === 1) { - this.insertIntoTextArea(this.search_results[0].sn); - } - } else { + ev.target.blur(); + const first_el = this.el.querySelector('.pick-category'); + this.navigator.select(first_el, 'right'); + } else if (ev.keyCode === converse.keycodes.TAB) { + ev.preventDefault(); + const match = find(_converse.emoji_shortnames, sn => _converse.FILTER_CONTAINS(sn, ev.target.value)); + match && this.filter(match, true); + } else if ( + ev.keyCode !== converse.keycodes.ENTER && + ev.keyCode !== converse.keycodes.DOWN_ARROW + ) { this.debouncedFilter(ev.target); } }, @@ -307,14 +398,13 @@ converse.plugins.add('converse-emoji-views', { }, chooseCategory (ev) { - ev.preventDefault(); - ev.stopPropagation(); - const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target; - const category = target.getAttribute("data-category").trim(); - // XXX: See above + ev.preventDefault && ev.preventDefault(); + ev.stopPropagation && ev.stopPropagation(); const input = this.el.querySelector('.emoji-search'); input.value = ''; - this.model.save({'current_category': category, 'query': undefined}); + const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target; + this.setCategoryForElement(target); + this.navigator.select(target); this.setScrollPosition(); }, @@ -322,7 +412,9 @@ converse.plugins.add('converse-emoji-views', { const category = this.model.get('current_category'); const el = this.el.querySelector('.emoji-picker__lists'); const heading = this.el.querySelector(`#emoji-picker-${category}`); - el.scrollTop = heading.offsetTop - heading.offsetHeight*3; + if (heading) { + el.scrollTop = heading.offsetTop - heading.offsetHeight*3; + } }, insertEmoji (ev) { @@ -340,6 +432,9 @@ converse.plugins.add('converse-emoji-views', { /************************ BEGIN Event Handlers ************************/ + + _converse.api.listen.on('chatBoxClosed', view => view.emoji_picker_view && view.emoji_picker_view.remove()); + _converse.api.listen.on('renderToolbar', view => { if (_converse.visible_toolbar_buttons.emoji) { const html = tpl_emoji_button({'tooltip_insert_smiley': __('Insert emojis')}); diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index dd7f98d01..7bf656ba6 100644 --- a/src/converse-muc-views.js +++ b/src/converse-muc-views.js @@ -1,13 +1,8 @@ -// Converse.js -// https://conversejs.org -// -// Copyright (c) 2013-2019, the Converse.js developers -// Licensed under the Mozilla Public License (MPLv2) -// /** * @module converse-muc-views - * @description - * XEP-0045 Multi-User Chat Views + * @copyright 2013-2019, the Converse.js developers + * @description XEP-0045 Multi-User Chat Views + * @license Mozilla Public License (MPLv2) */ import "converse-modal"; import "backbone.vdomview"; diff --git a/src/converse-singleton.js b/src/converse-singleton.js index 4f0123982..20ba47c7b 100644 --- a/src/converse-singleton.js +++ b/src/converse-singleton.js @@ -1,12 +1,8 @@ -// Converse.js -// https://conversejs.org -// -// Copyright (c) 2013-2019, the Converse.js developers -// Licensed under the Mozilla Public License (MPLv2) /** * @module converse-singleton - * @description - * A plugin which restricts Converse to only one chat. + * @copyright JC Brand + * @license Mozilla Public License (MPLv2) + * @description A plugin which restricts Converse to only one chat. */ import converse from "@converse/headless/converse-core"; diff --git a/src/dom-navigator.js b/src/dom-navigator.js new file mode 100644 index 000000000..78c6acd90 --- /dev/null +++ b/src/dom-navigator.js @@ -0,0 +1,420 @@ +/** + * @module dom-navigator + * @description A class for navigating the DOM with the keyboard + * This module started as a fork of Rubens Mariuzzo's dom-navigator. + * @copyright Rubens Mariuzzo, JC Brand + */ +import log from "@converse/headless/log"; +import u from './utils/html'; + + +/** + * Indicates if a given element is fully visible in the viewport. + * @param { Element } el The element to check. + * @return { Boolean } True if the given element is fully visible in the viewport, otherwise false. + */ +function inViewport(el) { + const rect = el.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + ); +} + +/** + * Return the absolute offset top of an element. + * @param el { Element } The element. + * @return { Number } The offset top. + */ +function absoluteOffsetTop(el) { + let offsetTop = 0; + do { + if (!isNaN(el.offsetTop)) { + offsetTop += el.offsetTop; + } + } while ((el = el.offsetParent)); + return offsetTop; +} + +/** + * Return the absolute offset left of an element. + * @param el { Element } The element. + * @return { Number } The offset left. + */ +function absoluteOffsetLeft(el) { + let offsetLeft = 0; + do { + if (!isNaN(el.offsetLeft)) { + offsetLeft += el.offsetLeft; + } + } while ((el = el.offsetParent)); + return offsetLeft; +} + + +/** + * Adds the ability to navigate the DOM with the arrow keys + * @class + * @namespace DOMNavigator + */ +class DOMNavigator { + /** + * Directions. + * @returns {{left: string, up: string, right: string, down: string}} + * @constructor + */ + static get DIRECTION () { + return { + left: 'left', + up: 'up', + right: 'right', + down: 'down' + }; + } + + /** + * The default options for the DOM navigator. + * @returns {{ + * down: number + * getSelector: null, + * jump_to_picked: null, + * jump_to_picked_direction: null, + * jump_to_picked_selector: string, + * left: number, + * onSelected: null, + * right: number, + * selected: string, + * up: number, + * }} + */ + static get DEFAULTS () { + return { + down: 40, + getSelector: null, + jump_to_picked: null, + jump_to_picked_direction: null, + jump_to_picked_selector: 'picked', + left: 37, + onSelected: null, + right: 39, + selected: 'selected', + selector: 'li', + up: 38, + }; + } + + /** + * Create a new DOM Navigator. + * @param { Element } container The container of the element to navigate. + * @param { Object } options The options to configure the DOM navigator. + * @param { Function } options.getSelector + * @param { Number } [options.down] - The keycode for navigating down + * @param { Number } [options.left] - The keycode for navigating left + * @param { Number } [options.right] - The keycode for navigating right + * @param { Number } [options.up] - The keycode for navigating up + * @param { String } [options.selected] - The class that should be added to the currently selected DOM element. + * @param { String } [options.jump_to_picked] - A selector, which if + * matched by the next element being navigated to, based on the direction + * given by `jump_to_picked_direction`, will cause navigation + * to jump to the element that matches the `jump_to_picked_selector`. + * For example, this is useful when navigating to tabs. You want to + * immediately navigate to the currently active tab instead of just + * navigating to the first tab. + * @param { String } [options.jump_to_picked_selector=picked] - The selector + * indicating the currently picked element to jump to. + * @param { String } [options.jump_to_picked_direction] - The direction for + * which jumping to the picked element should be enabled. + * @param { Function } [options.onSelected] - The callback function which + * should be called when en element gets selected. + * @constructor + */ + constructor (container, options) { + this.doc = window.document; + this.container = container; + this.scroll_container = options.scroll_container || container; + this.options = Object.assign({}, DOMNavigator.DEFAULTS, options); + this.init(); + } + + /** + * Initialize the navigator. + * @method DOMNavigator#init + */ + init () { + this.selected = null; + this.keydownHandler = null; + this.elements = {}; + // Create hotkeys map. + this.keys = {}; + this.keys[this.options.left] = DOMNavigator.DIRECTION.left; + this.keys[this.options.up] = DOMNavigator.DIRECTION.up; + this.keys[this.options.right] = DOMNavigator.DIRECTION.right; + this.keys[this.options.down] = DOMNavigator.DIRECTION.down; + } + + /** + * Enable this navigator. + * @method DOMNavigator#enable + */ + enable () { + this.getElements(); + this.keydownHandler = event => this.handleKeydown(event); + this.doc.addEventListener('keydown', this.keydownHandler); + this.enabled = true; + } + + /** + * Disable this navigator. + * @method DOMNavigator#disable + */ + disable () { + if (this.keydownHandler) { + this.doc.removeEventListener('keydown', this.keydownHandler); + } + this.unselect(); + this.elements = {}; + this.enabled = false; + } + + /** + * Destroy this navigator removing any event registered and any other data. + * @method DOMNavigator#destroy + */ + destroy () { + this.disable(); + if (this.container.domNavigator) { + delete this.container.domNavigator; + } + } + + getClosestElement (els, getDistance) { + const next = els.reduce((prev, curr) => { + const current_distance = getDistance(curr); + if (current_distance < prev.distance) { + return { + distance: current_distance, + element: curr + }; + } + return prev; + }, { + distance: Infinity + }); + return next.element; + } + + /** + * @method DOMNavigator#getNextElement + * @param {'down'|'right'|'left'|'up'} direction + * @returns { HTMLElement } + */ + getNextElement (direction) { + let el; + if (this.selected) { + if (direction === DOMNavigator.DIRECTION.right) { + const els = this.getElements(direction); + el = els.slice(els.indexOf(this.selected))[1]; + } else if (direction == DOMNavigator.DIRECTION.left) { + const els = this.getElements(direction); + el = els.slice(0, els.indexOf(this.selected)).pop() || this.selected; + } else if (direction == DOMNavigator.DIRECTION.down) { + const left = this.selected.offsetLeft; + const top = this.selected.offsetTop + this.selected.offsetHeight; + const els = this.elementsAfter(0, top); + const getDistance = el => Math.abs(el.offsetLeft - left) + Math.abs(el.offsetTop - top); + el = this.getClosestElement(els, getDistance); + } else if (direction == DOMNavigator.DIRECTION.up) { + const left = this.selected.offsetLeft; + const top = this.selected.offsetTop - 1; + const els = this.elementsBefore(Infinity, top); + const getDistance = el => Math.abs(left - el.offsetLeft) + Math.abs(top - el.offsetTop); + el = this.getClosestElement(els, getDistance); + } else { + throw new Error("getNextElement: invalid direction value"); + } + } else { + if (direction === DOMNavigator.DIRECTION.right || direction === DOMNavigator.DIRECTION.down) { + // If nothing is selected, we pretend that the first element is + // selected, so we return the next. + el = this.getElements(direction)[1]; + } else { + el = this.getElements(direction)[0] + } + } + + if (this.options.jump_to_picked && el && el.matches(this.options.jump_to_picked) && + direction === this.options.jump_to_picked_direction + ) { + el = this.container.querySelector(this.options.jump_to_picked_selector) || el; + } + return el; + } + + /** + * Select the given element. + * @method DOMNavigator#select + * @param { Element } el The DOM element to select. + * @param { string } [direction] The direction. + */ + select (el, direction) { + if (!el || el === this.selected) { + return; + } + this.unselect(); + direction && this.scrollTo(el, direction); + if (el.matches('input')) { + el.focus(); + } else { + u.addClass(this.options.selected, el); + } + this.selected = el; + this.options.onSelected && this.options.onSelected(el); + } + + /** + * Remove the current selection + * @method DOMNavigator#unselect + */ + unselect () { + if (this.selected) { + u.removeClass(this.options.selected, this.selected); + delete this.selected; + } + } + + /** + * Scroll the container to an element. + * @method DOMNavigator#scrollTo + * @param { HTMLElement } el The destination element. + * @param { String } direction The direction of the current navigation. + * @return void. + */ + scrollTo (el, direction) { + if (!this.inScrollContainerViewport(el)) { + const container = this.scroll_container; + if (!container.contains(el)) { + return; + } + switch (direction) { + case DOMNavigator.DIRECTION.left: + container.scrollLeft = el.offsetLeft - container.offsetLeft; + container.scrollTop = el.offsetTop - container.offsetTop; + break; + case DOMNavigator.DIRECTION.up: + container.scrollTop = el.offsetTop - container.offsetTop; + break; + case DOMNavigator.DIRECTION.right: + container.scrollLeft = el.offsetLeft - container.offsetLeft - (container.offsetWidth - el.offsetWidth); + container.scrollTop = el.offsetTop - container.offsetTop - (container.offsetHeight - el.offsetHeight); + break; + case DOMNavigator.DIRECTION.down: + container.scrollTop = el.offsetTop - container.offsetTop - (container.offsetHeight - el.offsetHeight); + break; + } + } else if (!inViewport(el)) { + switch (direction) { + case DOMNavigator.DIRECTION.left: + document.body.scrollLeft = absoluteOffsetLeft(el) - document.body.offsetLeft; + break; + case DOMNavigator.DIRECTION.up: + document.body.scrollTop = absoluteOffsetTop(el) - document.body.offsetTop; + break; + case DOMNavigator.DIRECTION.right: + document.body.scrollLeft = absoluteOffsetLeft(el) - document.body.offsetLeft - (document.documentElement.clientWidth - el.offsetWidth); + break; + case DOMNavigator.DIRECTION.down: + document.body.scrollTop = absoluteOffsetTop(el) - document.body.offsetTop - (document.documentElement.clientHeight - el.offsetHeight); + break; + } + } + } + + /** + * Indicate if an element is in the container viewport. + * @method DOMNavigator#inScrollContainerViewport + * @param { HTMLElement } el The element to check. + * @return { Boolean } true if the given element is in the container viewport, otherwise false. + */ + inScrollContainerViewport(el) { + const container = this.scroll_container; + // Check on left side. + if (el.offsetLeft - container.scrollLeft < container.offsetLeft) { + return false; + } + // Check on top side. + if (el.offsetTop - container.scrollTop < container.offsetTop) { + return false; + } + // Check on right side. + if ((el.offsetLeft + el.offsetWidth - container.scrollLeft) > (container.offsetLeft + container.offsetWidth)) { + return false; + } + // Check on down side. + if ((el.offsetTop + el.offsetHeight - container.scrollTop) > (container.offsetTop + container.offsetHeight)) { + return false; + } + return true; + } + + /** + * Find and store the navigable elements + * @method DOMNavigator#getElements + */ + getElements (direction) { + const selector = this.options.getSelector ? this.options.getSelector(direction) : this.options.selector; + if (!this.elements[selector]) { + this.elements[selector] = Array.from(this.container.querySelectorAll(selector)); + } + return this.elements[selector]; + } + + /** + * Return an array of navigable elements after an offset. + * @method DOMNavigator#elementsAfter + * @param { number } left The left offset. + * @param { number } top The top offset. + * @return { Array } An array of elements. + */ + elementsAfter (left, top) { + return this.getElements(DOMNavigator.DIRECTION.down).filter(el => el.offsetLeft >= left && el.offsetTop >= top); + } + + /** + * Return an array of navigable elements before an offset. + * @method DOMNavigator#elementsBefore + * @param { number } left The left offset. + * @param { number } top The top offset. + * @return { Array } An array of elements. + */ + elementsBefore (left, top) { + return this.getElements(DOMNavigator.DIRECTION.up).filter(el => el.offsetLeft <= left && el.offsetTop <= top); + } + + /** + * Handle the key down event. + * @method DOMNavigator#handleKeydown + * @param { Event } event The event object. + */ + handleKeydown (event) { + const direction = this.keys[event.which]; + if (direction) { + event.preventDefault(); + event.stopPropagation(); + let next; + if (event.shiftKey && direction === DOMNavigator.DIRECTION.up) { + // shift-up goes to the first element + next = this.getElements(direction)[0]; + } else if (event.shiftKey && direction === DOMNavigator.DIRECTION.down) { + // shift-down goes to the last element + next = Array.from(this.getElements(direction)).pop(); + } else { + next = this.getNextElement(direction, event); + } + this.select(next, direction); + } + } +} + +export default DOMNavigator; diff --git a/src/headless/converse-core.js b/src/headless/converse-core.js index b1602eadb..5e5a3f2e3 100644 --- a/src/headless/converse-core.js +++ b/src/headless/converse-core.js @@ -149,21 +149,6 @@ _converse.IllegalMessage = IllegalMessage; // Make converse pluggable pluggable.enable(_converse, '_converse', 'pluggable'); -_converse.keycodes = { - TAB: 9, - ENTER: 13, - SHIFT: 16, - CTRL: 17, - ALT: 18, - ESCAPE: 27, - UP_ARROW: 38, - DOWN_ARROW: 40, - FORWARD_SLASH: 47, - AT: 50, - META: 91, - META_RIGHT: 93 -}; - // Module-level constants _converse.STATUS_WEIGHTS = { 'offline': 6, @@ -1732,6 +1717,23 @@ window.converse = window.converse || {}; * @namespace converse */ Object.assign(window.converse, { + keycodes: { + TAB: 9, + ENTER: 13, + SHIFT: 16, + CTRL: 17, + ALT: 18, + ESCAPE: 27, + LEFT_ARROW: 37, + UP_ARROW: 38, + RIGHT_ARROW: 39, + DOWN_ARROW: 40, + FORWARD_SLASH: 47, + AT: 50, + META: 91, + META_RIGHT: 93 + }, + /** * Public API method which initializes Converse. * This method must always be called when using Converse. diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 25fdd96e6..dc3d1353e 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -1,13 +1,8 @@ -// Converse.js -// https://conversejs.org -// -// Copyright (c) 2013-2019, the Converse.js developers -// Licensed under the Mozilla Public License (MPLv2) -// /** * @module converse-muc - * @description - * Implements the non-view logic for XEP-0045 Multi-User Chat + * @copyright The Converse.js developers + * @license Mozilla Public License (MPLv2) + * @description Implements the non-view logic for XEP-0045 Multi-User Chat */ import "./converse-chat"; import "./converse-disco"; diff --git a/src/utils/html.js b/src/utils/html.js index b8ecc79cd..df6bd2304 100644 --- a/src/utils/html.js +++ b/src/utils/html.js @@ -279,11 +279,23 @@ u.hasClass = function (className, el) { return (el instanceof Element) && el.classList.contains(className); }; +/** + * Add a class to an element. + * @method u#addClass + * @param {string} className + * @param {Element} el + */ u.addClass = function (className, el) { (el instanceof Element) && el.classList.add(className); return el; } +/** + * Remove a class from an element. + * @method u#removeClass + * @param {string} className + * @param {Element} el + */ u.removeClass = function (className, el) { (el instanceof Element) && el.classList.remove(className); return el;