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
This commit is contained in:
toasted-nutbread 2020-12-28 17:41:59 -05:00 committed by GitHub
parent c03340c4aa
commit b6038c87b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 179 additions and 34 deletions

View File

@ -361,8 +361,16 @@ h3 {
opacity: 0; opacity: 0;
margin: 0; margin: 0;
padding: 0; padding: 0;
outline: none;
background-color: transparent; background-color: transparent;
display: inline; display: inline;
width: 0;
height: 0;
line-height: 0;
user-select: none;
}
#content-scroll-focus::-moz-focus-inner {
border: 0;
} }
.content-dimmer { .content-dimmer {
display: block; display: block;

View File

@ -62,6 +62,7 @@
<script src="/mixed/js/comm.js"></script> <script src="/mixed/js/comm.js"></script>
<script src="/mixed/js/api.js"></script> <script src="/mixed/js/api.js"></script>
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/mixed/js/html-template-collection.js"></script> <script src="/mixed/js/html-template-collection.js"></script>
<script src="/bg/js/settings/settings-controller.js"></script> <script src="/bg/js/settings/settings-controller.js"></script>
<script src="/bg/js/settings/backup-controller.js"></script> <script src="/bg/js/settings/backup-controller.js"></script>

View File

@ -15,13 +15,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
/* global
* DocumentFocusController
*/
function setupEnvironmentInfo() { function setupEnvironmentInfo() {
const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
document.documentElement.dataset.manifestVersion = `${manifestVersion}`; document.documentElement.dataset.manifestVersion = `${manifestVersion}`;
} }
(() => { (() => {
document.querySelector('#content-scroll-focus').focus(); const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
document.documentElement.dataset.loaded = 'true'; document.documentElement.dataset.loaded = 'true';
setupEnvironmentInfo(); setupEnvironmentInfo();
})(); })();

View File

@ -17,6 +17,7 @@
/* global /* global
* BackupController * BackupController
* DocumentFocusController
* SettingsController * SettingsController
* api * api
*/ */
@ -47,7 +48,8 @@ function getOperatingSystemDisplayName(os) {
(async () => { (async () => {
try { try {
document.querySelector('#content-scroll-focus').focus(); const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const manifest = chrome.runtime.getManifest(); const manifest = chrome.runtime.getManifest();
const language = chrome.i18n.getUILanguage(); const language = chrome.i18n.getUILanguage();

View File

@ -16,6 +16,7 @@
*/ */
/* global /* global
* DocumentFocusController
* api * api
*/ */
@ -52,7 +53,9 @@ async function setPermissionsGranted(permissions, shouldHave) {
(async () => { (async () => {
try { try {
document.querySelector('#content-scroll-focus').focus(); const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
document.querySelector('#extension-id-example').textContent = chrome.runtime.getURL('/'); document.querySelector('#extension-id-example').textContent = chrome.runtime.getURL('/');
api.forwardLogsToBackend(); api.forwardLogsToBackend();

View File

@ -17,6 +17,7 @@
/* global /* global
* DisplaySearch * DisplaySearch
* DocumentFocusController
* JapaneseUtil * JapaneseUtil
* api * api
* wanakana * wanakana
@ -24,11 +25,14 @@
(async () => { (async () => {
try { try {
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
api.forwardLogsToBackend(); api.forwardLogsToBackend();
await yomichan.backendReady(); await yomichan.backendReady();
const japaneseUtil = new JapaneseUtil(wanakana); const japaneseUtil = new JapaneseUtil(wanakana);
const displaySearch = new DisplaySearch(japaneseUtil); const displaySearch = new DisplaySearch(japaneseUtil, documentFocusController);
await displaySearch.prepare(); await displaySearch.prepare();
document.documentElement.dataset.loaded = 'true'; document.documentElement.dataset.loaded = 'true';

View File

@ -24,8 +24,8 @@
*/ */
class DisplaySearch extends Display { class DisplaySearch extends Display {
constructor(japaneseUtil) { constructor(japaneseUtil, documentFocusController) {
super('search', japaneseUtil); super('search', japaneseUtil, documentFocusController);
this._searchButton = document.querySelector('#search-button'); this._searchButton = document.querySelector('#search-button');
this._queryInput = document.querySelector('#search-textbox'); this._queryInput = document.querySelector('#search-textbox');
this._introElement = document.querySelector('#intro'); this._introElement = document.querySelector('#intro');

View File

@ -23,6 +23,7 @@
* ClipboardPopupsController * ClipboardPopupsController
* DictionaryController * DictionaryController
* DictionaryImportController * DictionaryImportController
* DocumentFocusController
* GenericSettingController * GenericSettingController
* ModalController * ModalController
* NestedPopupsController * NestedPopupsController
@ -53,7 +54,8 @@ async function setupGenericSettingsController(genericSettingController) {
(async () => { (async () => {
try { try {
document.querySelector('#content-scroll-focus').focus(); const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const statusFooter = new StatusFooter(document.querySelector('.status-footer-container')); const statusFooter = new StatusFooter(document.querySelector('.status-footer-container'));
statusFooter.prepare(); statusFooter.prepare();

View File

@ -18,6 +18,7 @@
/* global /* global
* DictionaryController * DictionaryController
* DictionaryImportController * DictionaryImportController
* DocumentFocusController
* GenericSettingController * GenericSettingController
* ModalController * ModalController
* ScanInputsSimpleController * ScanInputsSimpleController
@ -42,7 +43,8 @@ async function setupGenericSettingsController(genericSettingController) {
(async () => { (async () => {
try { try {
document.querySelector('#content-scroll-focus').focus(); const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const statusFooter = new StatusFooter(document.querySelector('.status-footer-container')); const statusFooter = new StatusFooter(document.querySelector('.status-footer-container'));
statusFooter.prepare(); statusFooter.prepare();

View File

@ -224,6 +224,8 @@ THE SOFTWARE.
</div></div> </div></div>
<!-- Scripts --> <!-- Scripts -->
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/bg/js/generic-page-main.js"></script> <script src="/bg/js/generic-page-main.js"></script>
</body> </body>

View File

@ -138,6 +138,8 @@
<script src="/mixed/js/comm.js"></script> <script src="/mixed/js/comm.js"></script>
<script src="/mixed/js/api.js"></script> <script src="/mixed/js/api.js"></script>
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/bg/js/permissions-main.js"></script> <script src="/bg/js/permissions-main.js"></script>
</body> </body>

View File

@ -79,6 +79,7 @@
<script src="/mixed/js/japanese.js"></script> <script src="/mixed/js/japanese.js"></script>
<script src="/mixed/js/cache-map.js"></script> <script src="/mixed/js/cache-map.js"></script>
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/mixed/js/document-util.js"></script> <script src="/mixed/js/document-util.js"></script>
<script src="/fg/js/dom-text-scanner.js"></script> <script src="/fg/js/dom-text-scanner.js"></script>
<script src="/fg/js/text-source-range.js"></script> <script src="/fg/js/text-source-range.js"></script>

View File

@ -2555,6 +2555,7 @@
<script src="/mixed/js/audio-system.js"></script> <script src="/mixed/js/audio-system.js"></script>
<script src="/mixed/js/cache-map.js"></script> <script src="/mixed/js/cache-map.js"></script>
<script src="/mixed/js/dictionary-data-util.js"></script> <script src="/mixed/js/dictionary-data-util.js"></script>
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/mixed/js/document-util.js"></script> <script src="/mixed/js/document-util.js"></script>
<script src="/mixed/js/dom-data-binder.js"></script> <script src="/mixed/js/dom-data-binder.js"></script>
<script src="/mixed/js/html-template-collection.js"></script> <script src="/mixed/js/html-template-collection.js"></script>

View File

@ -334,6 +334,7 @@
<script src="/mixed/js/api.js"></script> <script src="/mixed/js/api.js"></script>
<script src="/mixed/js/cache-map.js"></script> <script src="/mixed/js/cache-map.js"></script>
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/mixed/js/document-util.js"></script> <script src="/mixed/js/document-util.js"></script>
<script src="/mixed/js/dom-data-binder.js"></script> <script src="/mixed/js/dom-data-binder.js"></script>
<script src="/mixed/js/html-template-collection.js"></script> <script src="/mixed/js/html-template-collection.js"></script>

View File

@ -95,6 +95,7 @@
<script src="/mixed/js/display-generator.js"></script> <script src="/mixed/js/display-generator.js"></script>
<script src="/mixed/js/display-history.js"></script> <script src="/mixed/js/display-history.js"></script>
<script src="/mixed/js/display-notification.js"></script> <script src="/mixed/js/display-notification.js"></script>
<script src="/mixed/js/document-focus-controller.js"></script>
<script src="/mixed/js/dynamic-loader.js"></script> <script src="/mixed/js/dynamic-loader.js"></script>
<script src="/mixed/js/frame-endpoint.js"></script> <script src="/mixed/js/frame-endpoint.js"></script>
<script src="/mixed/js/media-loader.js"></script> <script src="/mixed/js/media-loader.js"></script>

View File

@ -17,17 +17,21 @@
/* global /* global
* Display * Display
* DocumentFocusController
* JapaneseUtil * JapaneseUtil
* api * api
*/ */
(async () => { (async () => {
try { try {
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
api.forwardLogsToBackend(); api.forwardLogsToBackend();
await yomichan.backendReady(); await yomichan.backendReady();
const japaneseUtil = new JapaneseUtil(null); const japaneseUtil = new JapaneseUtil(null);
const display = new Display('popup', japaneseUtil); const display = new Display('popup', japaneseUtil, documentFocusController);
await display.prepare(); await display.prepare();
display.initializeState(); display.initializeState();

View File

@ -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 */ /* Scrollbars */
:root:not([data-theme=default]) .scrollbar { :root:not([data-theme=default]) .scrollbar {
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);

View File

@ -35,10 +35,11 @@
*/ */
class Display extends EventDispatcher { class Display extends EventDispatcher {
constructor(pageType, japaneseUtil) { constructor(pageType, japaneseUtil, documentFocusController) {
super(); super();
this._pageType = pageType; this._pageType = pageType;
this._japaneseUtil = japaneseUtil; this._japaneseUtil = japaneseUtil;
this._documentFocusController = documentFocusController;
this._container = document.querySelector('#definitions'); this._container = document.querySelector('#definitions');
this._definitions = []; this._definitions = [];
this._optionsContext = {depth: 0, url: window.location.href}; this._optionsContext = {depth: 0, url: window.location.href};
@ -95,7 +96,6 @@ class Display extends EventDispatcher {
this._updateAdderButtonsPromise = Promise.resolve(); this._updateAdderButtonsPromise = Promise.resolve();
this._contentScrollElement = document.querySelector('#content-scroll'); this._contentScrollElement = document.querySelector('#content-scroll');
this._contentScrollBodyElement = document.querySelector('#content-body'); this._contentScrollBodyElement = document.querySelector('#content-body');
this._contentScrollFocusElement = document.querySelector('#content-scroll-focus');
this._windowScroll = new WindowScroll(this._contentScrollElement); this._windowScroll = new WindowScroll(this._contentScrollElement);
this._contentSidebar = document.querySelector('#content-sidebar'); this._contentSidebar = document.querySelector('#content-sidebar');
this._closeButton = document.querySelector('#close-button'); this._closeButton = document.querySelector('#close-button');
@ -218,7 +218,6 @@ class Display extends EventDispatcher {
['popupMessage', {async: 'dynamic', handler: this._onDirectMessage.bind(this)}] ['popupMessage', {async: 'dynamic', handler: this._onDirectMessage.bind(this)}]
]); ]);
window.addEventListener('message', this._onWindowMessage.bind(this), false); window.addEventListener('message', this._onWindowMessage.bind(this), false);
window.addEventListener('focus', this._onWindowFocus.bind(this), false);
if (this._pageType === 'popup' && documentElement !== null) { if (this._pageType === 'popup' && documentElement !== null) {
documentElement.addEventListener('mouseup', this._onDocumentElementMouseUp.bind(this), false); documentElement.addEventListener('mouseup', this._onDocumentElementMouseUp.bind(this), false);
@ -241,9 +240,6 @@ class Display extends EventDispatcher {
if (this._frameResizeHandle !== null) { if (this._frameResizeHandle !== null) {
this._frameResizeHandle.addEventListener('mousedown', this._onFrameResizerMouseDown.bind(this), false); this._frameResizeHandle.addEventListener('mousedown', this._onFrameResizerMouseDown.bind(this), false);
} }
// Final preparation
this._updateFocusedElement();
} }
initializeState() { initializeState() {
@ -457,8 +453,7 @@ class Display extends EventDispatcher {
} }
blurElement(element) { blurElement(element) {
element.blur(); this._documentFocusController.blurElement(element);
this._updateFocusedElement();
} }
searchLast() { searchLast() {
@ -711,10 +706,6 @@ class Display extends EventDispatcher {
} }
} }
_onWindowFocus() {
this._updateFocusedElement();
}
async _onKanjiLookup(e) { async _onKanjiLookup(e) {
try { try {
e.preventDefault(); e.preventDefault();
@ -1633,19 +1624,6 @@ class Display extends EventDispatcher {
await this.setOptionsContext(optionsContext); 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) { _setContentScale(scale) {
const body = document.body; const body = document.body;
if (body === null) { return; } if (body === null) { return; }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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;
}
}