From b6038c87b66630b341e431a4722856c9a3a282ed Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Mon, 28 Dec 2020 17:41:59 -0500 Subject: Improve document focus control (#1167) * Improve styles for #content-scroll-focus * Create new class to manage and control document focus * Use new focus class * Add a check to prevent redundant .blur calls --- ext/mixed/css/display.css | 18 +++++ ext/mixed/js/display.js | 28 +------- ext/mixed/js/document-focus-controller.js | 110 ++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 ext/mixed/js/document-focus-controller.js (limited to 'ext/mixed') diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index a6ca2810..f0f39d07 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -244,6 +244,24 @@ a { } +/* Selection */ +#content-scroll-focus { + opacity: 0; + margin: 0; + padding: 0; + outline: none; + background-color: transparent; + display: inline; + width: 0; + height: 0; + line-height: 0; + user-select: none; +} +#content-scroll-focus::-moz-focus-inner { + border: 0; +} + + /* Scrollbars */ :root:not([data-theme=default]) .scrollbar { scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 7491cd60..b9ea3802 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -35,10 +35,11 @@ */ class Display extends EventDispatcher { - constructor(pageType, japaneseUtil) { + constructor(pageType, japaneseUtil, documentFocusController) { super(); this._pageType = pageType; this._japaneseUtil = japaneseUtil; + this._documentFocusController = documentFocusController; this._container = document.querySelector('#definitions'); this._definitions = []; this._optionsContext = {depth: 0, url: window.location.href}; @@ -95,7 +96,6 @@ class Display extends EventDispatcher { this._updateAdderButtonsPromise = Promise.resolve(); this._contentScrollElement = document.querySelector('#content-scroll'); this._contentScrollBodyElement = document.querySelector('#content-body'); - this._contentScrollFocusElement = document.querySelector('#content-scroll-focus'); this._windowScroll = new WindowScroll(this._contentScrollElement); this._contentSidebar = document.querySelector('#content-sidebar'); this._closeButton = document.querySelector('#close-button'); @@ -218,7 +218,6 @@ class Display extends EventDispatcher { ['popupMessage', {async: 'dynamic', handler: this._onDirectMessage.bind(this)}] ]); window.addEventListener('message', this._onWindowMessage.bind(this), false); - window.addEventListener('focus', this._onWindowFocus.bind(this), false); if (this._pageType === 'popup' && documentElement !== null) { documentElement.addEventListener('mouseup', this._onDocumentElementMouseUp.bind(this), false); @@ -241,9 +240,6 @@ class Display extends EventDispatcher { if (this._frameResizeHandle !== null) { this._frameResizeHandle.addEventListener('mousedown', this._onFrameResizerMouseDown.bind(this), false); } - - // Final preparation - this._updateFocusedElement(); } initializeState() { @@ -457,8 +453,7 @@ class Display extends EventDispatcher { } blurElement(element) { - element.blur(); - this._updateFocusedElement(); + this._documentFocusController.blurElement(element); } searchLast() { @@ -711,10 +706,6 @@ class Display extends EventDispatcher { } } - _onWindowFocus() { - this._updateFocusedElement(); - } - async _onKanjiLookup(e) { try { e.preventDefault(); @@ -1633,19 +1624,6 @@ class Display extends EventDispatcher { await this.setOptionsContext(optionsContext); } - _updateFocusedElement() { - const target = this._contentScrollFocusElement; - if (target === null) { return; } - const {activeElement} = document; - if ( - activeElement === null || - activeElement === document.documentElement || - activeElement === document.body - ) { - target.focus({preventScroll: true}); - } - } - _setContentScale(scale) { const body = document.body; if (body === null) { return; } diff --git a/ext/mixed/js/document-focus-controller.js b/ext/mixed/js/document-focus-controller.js new file mode 100644 index 00000000..985e9f5a --- /dev/null +++ b/ext/mixed/js/document-focus-controller.js @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * This class is used to control the document focus when a non-body element contains the main scrollbar. + * Web browsers will not automatically focus a custom element with the scrollbar on load, which results in + * keyboard shortcuts (e.g. arrow keys) not controlling page scroll. Instead, this class will manually + * focus a dummy element inside the main content, which gives keyboard scroll focus to that element. + */ +class DocumentFocusController { + constructor() { + this._contentScrollFocusElement = document.querySelector('#content-scroll-focus'); + } + + prepare() { + window.addEventListener('focus', this._onWindowFocus.bind(this), false); + this._updateFocusedElement(false); + } + + blurElement(element) { + if (document.activeElement !== element) { return; } + element.blur(); + this._updateFocusedElement(false); + } + + // Private + + _onWindowFocus() { + this._updateFocusedElement(false); + } + + _updateFocusedElement(force) { + const target = this._contentScrollFocusElement; + if (target === null) { return; } + + const {activeElement} = document; + if ( + force || + activeElement === null || + activeElement === document.documentElement || + activeElement === document.body + ) { + // Get selection + const selection = window.getSelection(); + const selectionRanges1 = this._getSelectionRanges(selection); + + // Note: This function will cause any selected text to be deselected on Firefox. + target.focus({preventScroll: true}); + + // Restore selection + const selectionRanges2 = this._getSelectionRanges(selection); + if (!this._areRangesSame(selectionRanges1, selectionRanges2)) { + this._setSelectionRanges(selection, selectionRanges1); + } + } + } + + _getSelectionRanges(selection) { + const ranges = []; + for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { + ranges.push(selection.getRangeAt(i)); + } + return ranges; + } + + _setSelectionRanges(selection, ranges) { + selection.removeAllRanges(); + for (const range of ranges) { + selection.addRange(range); + } + } + + _areRangesSame(ranges1, ranges2) { + const ii = ranges1.length; + if (ii !== ranges2.length) { + return false; + } + + for (let i = 0; i < ii; ++i) { + const range1 = ranges1[i]; + const range2 = ranges2[i]; + try { + if ( + range1.compareBoundaryPoints(Range.START_TO_START, range2) !== 0 || + range1.compareBoundaryPoints(Range.END_TO_END, range2) !== 0 + ) { + return false; + } + } catch (e) { + return false; + } + } + + return true; + } +} -- cgit v1.2.3