blob: 399c8d5da85a0561833a5b3fc9e59818a271d278 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {AutomationPredicate} from '../../common/automation_predicate.js';
import {AutomationUtil} from '../../common/automation_util.js';
import {CursorRange} from '../../common/cursors/range.js';
import {InstanceChecker} from '../../common/instance_checker.js';
import {AbstractEarcons} from '../common/abstract_earcons.js';
import {ExtensionBridge} from '../common/extension_bridge.js';
import {LocaleOutputHelper} from '../common/locale_output_helper.js';
import {Msgs} from '../common/msgs.js';
import {PanelCommand, PanelCommandType} from '../common/panel_command.js';
import {QueueMode, TtsSpeechProperties} from '../common/tts_interface.js';
import {JaPhoneticMap} from '../third_party/tamachiyomi/ja_phonetic_map.js';
import {BrailleCommandHandler} from './braille/braille_command_handler.js';
import {ChromeVox} from './chromevox.js';
import {ChromeVoxState} from './chromevox_state.js';
import {ChromeVoxBackground} from './classic_background.js';
import {CommandHandler} from './command_handler.js';
import {CommandHandlerInterface} from './command_handler_interface.js';
import {ConsoleTts} from './console_tts.js';
import {DesktopAutomationHandler} from './desktop_automation_handler.js';
import {DesktopAutomationInterface} from './desktop_automation_interface.js';
import {DownloadHandler} from './download_handler.js';
import {Earcons} from './earcons.js';
import {FindHandler} from './find_handler.js';
import {FocusAutomationHandler} from './focus_automation_handler.js';
import {FocusBounds} from './focus_bounds.js';
import {GestureCommandHandler} from './gesture_command_handler.js';
import {BackgroundKeyboardHandler} from './keyboard_handler.js';
import {LiveRegions} from './live_regions.js';
import {LogStore} from './logging/log_store.js';
import {MathHandler} from './math_handler.js';
import {MediaAutomationHandler} from './media_automation_handler.js';
import {Output} from './output/output.js';
import {OutputEventType} from './output/output_types.js';
import {PageLoadSoundHandler} from './page_load_sound_handler.js';
import {PanelBackground} from './panel/panel_background.js';
import {ChromeVoxPrefs} from './prefs.js';
import {RangeAutomationHandler} from './range_automation_handler.js';
import {TtsBackground} from './tts_background.js';
/**
* @fileoverview The entry point for all ChromeVox related code for the
* background page.
*/
const Dir = constants.Dir;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
/** ChromeVox background page. */
export class Background extends ChromeVoxState {
constructor() {
super();
/** @private {TtsBackground} */
this.backgroundTts_ = null;
/** @private {CursorRange} */
this.currentRange_ = null;
/** @private {!AbstractEarcons} */
this.earcons_ = new Earcons();
/** @private {boolean} */
this.isReadingContinuously_ = false;
/** @private {string|undefined} */
this.lastClipboardEvent_;
/** @private {CursorRange} */
this.pageSel_ = null;
/** @private {CursorRange} */
this.previousRange_ = null;
/** @private {boolean} */
this.talkBackEnabled_ = false;
this.init_();
}
/** @private */
init_() {
// Initialize legacy background page first.
ChromeVoxBackground.init(this);
// Read-only earcons.
Object.defineProperty(ChromeVox, 'earcons', {
get: () => this.earcons_,
});
chrome.clipboard.onClipboardDataChanged.addListener(() => {
this.onClipboardDataChanged_();
});
document.addEventListener('copy', event => {
this.onClipboardCopyEvent_(event);
});
BackgroundKeyboardHandler.init();
ConsoleTts.init();
DesktopAutomationHandler.init();
DownloadHandler.init();
FindHandler.init();
FocusAutomationHandler.init();
JaPhoneticData.init(JaPhoneticMap.MAP);
LiveRegions.init(this);
LocaleOutputHelper.init();
LogStore.init();
MediaAutomationHandler.init();
PageLoadSoundHandler.init();
PanelBackground.init();
RangeAutomationHandler.init();
chrome.accessibilityPrivate.onAnnounceForAccessibility.addListener(
announceText => {
ChromeVox.tts.speak(announceText.join(' '), QueueMode.FLUSH);
});
chrome.accessibilityPrivate.onCustomSpokenFeedbackToggled.addListener(
enabled => this.talkBackEnabled_ = enabled);
chrome.accessibilityPrivate.onShowChromeVoxTutorial.addListener(() => {
(new PanelCommand(PanelCommandType.TUTORIAL)).send();
});
// Set the darkScreen state to false, since the display will be on whenever
// ChromeVox starts.
sessionStorage.setItem('darkScreen', 'false');
}
/** @override */
getCurrentRange() {
if (this.currentRange_ && this.currentRange_.isValid()) {
return this.currentRange_;
}
return null;
}
/** @override */
get backgroundTts() {
return this.backgroundTts_;
}
/** @override */
get isReadingContinuously() {
return this.isReadingContinuously_;
}
/** @override */
get pageSel() {
return this.pageSel_;
}
/** @override */
get talkBackEnabled() {
return this.talkBackEnabled_;
}
/** @override */
getCurrentRangeWithoutRecovery() {
return this.currentRange_;
}
/**
* @param {CursorRange} newRange The new range.
* @param {boolean=} opt_fromEditing
* @override
*/
setCurrentRange(newRange, opt_fromEditing) {
// Clear anything that was frozen on the braille display whenever
// the user navigates.
ChromeVox.braille.thaw();
// There's nothing to be updated in this case.
if ((!newRange && !this.currentRange_) ||
(newRange && !newRange.isValid())) {
FocusBounds.set([]);
return;
}
this.previousRange_ = this.currentRange_;
this.currentRange_ = newRange;
ChromeVoxState.observers.forEach(
observer => observer.onCurrentRangeChanged(newRange, opt_fromEditing));
if (!this.currentRange_) {
FocusBounds.set([]);
return;
}
const start = this.currentRange_.start.node;
start.makeVisible();
start.setAccessibilityFocus();
const root = AutomationUtil.getTopLevelRoot(start);
if (!root || root.role === RoleType.DESKTOP || root === start) {
return;
}
const position = {};
const loc = start.unclippedLocation;
position.x = loc.left + loc.width / 2;
position.y = loc.top + loc.height / 2;
let url = root.docUrl;
url = url.substring(0, url.indexOf('#')) || url;
ChromeVox.position[url] = position;
}
/** @override */
set backgroundTts(newBackgroundTts) {
this.backgroundTts_ = newBackgroundTts;
}
/** @override */
set isReadingContinuously(newValue) {
this.isReadingContinuously_ = newValue;
}
/** @override */
set pageSel(newPageSel) {
this.pageSel_ = newPageSel;
}
/** @override */
get typingEcho() {
return parseInt(localStorage['typingEcho'], 10) || 0;
}
/** @override */
set typingEcho(newTypingEcho) {
localStorage['typingEcho'] = newTypingEcho;
}
/**
* Navigate to the given range - it both sets the range and outputs it.
* @param {!CursorRange} range The new range.
* @param {boolean=} opt_focus Focus the range; defaults to true.
* @param {TtsSpeechProperties=} opt_speechProps Speech properties.
* @param {boolean=} opt_skipSettingSelection If true, does not set
* the selection, otherwise it does by default.
* @override
*/
navigateToRange(range, opt_focus, opt_speechProps, opt_skipSettingSelection) {
opt_focus = opt_focus === undefined ? true : opt_focus;
opt_speechProps = opt_speechProps || new TtsSpeechProperties();
opt_skipSettingSelection = opt_skipSettingSelection || false;
const prevRange = this.currentRange_;
// Specialization for math output.
let skipOutput = false;
if (MathHandler.init(range)) {
skipOutput = MathHandler.instance.speak();
opt_focus = false;
}
if (opt_focus) {
this.setFocusToRange_(range, prevRange);
}
this.setCurrentRange(range);
const o = new Output();
let selectedRange;
let msg;
if (this.pageSel_ && this.pageSel_.isValid() && range.isValid()) {
// Suppress hints.
o.withoutHints();
// Selection across roots isn't supported.
const pageRootStart = this.pageSel_.start.node.root;
const pageRootEnd = this.pageSel_.end.node.root;
const curRootStart = range.start.node.root;
const curRootEnd = range.end.node.root;
// Deny crossing over the start of the page selection and roots.
if (pageRootStart !== pageRootEnd || pageRootStart !== curRootStart ||
pageRootEnd !== curRootEnd) {
o.format('@end_selection');
DesktopAutomationInterface.instance.ignoreDocumentSelectionFromAction(
false);
this.pageSel_ = null;
} else {
// Expand or shrink requires different feedback.
// Page sel is the only place in ChromeVox where we used directed
// selections. It is important to keep track of the directedness in
// places, but when comparing to other ranges, take the undirected
// range.
const dir = this.pageSel_.normalize().compare(range);
if (dir) {
// Directed expansion.
msg = '@selected';
} else {
// Directed shrink.
msg = '@unselected';
selectedRange = prevRange;
}
const wasBackwardSel =
this.pageSel_.start.compare(this.pageSel_.end) === Dir.BACKWARD ||
dir === Dir.BACKWARD;
this.pageSel_ = new CursorRange(
this.pageSel_.start, wasBackwardSel ? range.start : range.end);
if (this.pageSel_) {
this.pageSel_.select();
}
}
} else if (!opt_skipSettingSelection) {
// Ensure we don't select the editable when we first encounter it.
let lca = null;
if (range.start.node && prevRange.start.node) {
lca = AutomationUtil.getLeastCommonAncestor(
prevRange.start.node, range.start.node);
}
if (!lca || lca.state[StateType.EDITABLE] ||
!range.start.node.state[StateType.EDITABLE]) {
range.select();
}
}
o.withRichSpeechAndBraille(
selectedRange || range, prevRange, OutputEventType.NAVIGATE)
.withInitialSpeechProperties(opt_speechProps);
if (msg) {
o.format(msg);
}
if (!skipOutput) {
o.go();
}
}
/** @override */
onBrailleKeyEvent(evt, content) {
return BrailleCommandHandler.onBrailleKeyEvent(evt, content);
}
/** @override */
restoreLastValidRangeIfNeeded() {
// Never restore range when TalkBack is enabled as commands such as
// Search+Left, go directly to TalkBack.
if (this.talkBackEnabled) {
return;
}
if (!this.currentRange_ || !this.currentRange_.isValid()) {
this.setCurrentRange(this.previousRange_);
}
}
/** @override */
readNextClipboardDataChange() {
this.lastClipboardEvent_ = 'copy';
}
/**
* Processes the copy clipboard event.
* @param {!Event} evt
* @private
*/
onClipboardCopyEvent_(evt) {
// This should always be 'copy', but is still important to set for the below
// extension event.
this.lastClipboardEvent_ = evt.type;
}
/** @private */
onClipboardDataChanged_() {
// A DOM-based clipboard event always comes before this Chrome extension
// clipboard event. We only care about 'copy' events, which gets set above.
if (!this.lastClipboardEvent_) {
return;
}
const eventType = this.lastClipboardEvent_;
this.lastClipboardEvent_ = undefined;
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.focus();
document.execCommand('paste');
const clipboardContent = textarea.value;
textarea.remove();
ChromeVox.tts.speak(
Msgs.getMsg(eventType, [clipboardContent]), QueueMode.FLUSH);
ChromeVoxState.instance.pageSel = null;
}
/** @private */
async setCurrentRangeToFocus_() {
const focus =
await new Promise(resolve => chrome.automation.getFocus(resolve));
if (focus) {
this.setCurrentRange(CursorRange.fromNode(focus));
} else {
this.setCurrentRange(null);
}
}
/**
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @private
*/
setFocusToRange_(range, prevRange) {
const start = range.start.node;
const end = range.end.node;
// First, see if we've crossed a root. Remove once webview handles focus
// correctly.
if (prevRange && prevRange.start.node && start) {
const entered =
AutomationUtil.getUniqueAncestors(prevRange.start.node, start);
entered
.filter(f => {
return f.role === RoleType.PLUGIN_OBJECT ||
f.role === RoleType.IFRAME;
})
.forEach(container => {
if (!container.state[StateType.FOCUSED]) {
container.focus();
}
});
}
if (start.state[StateType.FOCUSED] || end.state[StateType.FOCUSED]) {
return;
}
const isFocusableLinkOrControl = node => node.state[StateType.FOCUSABLE] &&
AutomationPredicate.linkOrControl(node);
// Next, try to focus the start or end node.
if (!AutomationPredicate.structuralContainer(start) &&
start.state[StateType.FOCUSABLE]) {
if (!start.state[StateType.FOCUSED]) {
start.focus();
}
return;
} else if (
!AutomationPredicate.structuralContainer(end) &&
end.state[StateType.FOCUSABLE]) {
if (!end.state[StateType.FOCUSED]) {
end.focus();
}
return;
}
// If a common ancestor of |start| and |end| is a link, focus that.
let ancestor = AutomationUtil.getLeastCommonAncestor(start, end);
while (ancestor && ancestor.root === start.root) {
if (isFocusableLinkOrControl(ancestor)) {
if (!ancestor.state[StateType.FOCUSED]) {
ancestor.focus();
}
return;
}
ancestor = ancestor.parent;
}
// If nothing is focusable, set the sequential focus navigation starting
// point, which ensures that the next time you press Tab, you'll reach
// the next or previous focusable node from |start|.
if (!start.state[StateType.OFFSCREEN]) {
start.setSequentialFocusNavigationStartingPoint();
}
}
/**
* Converts a list of globs, as used in the extension manifest, to a regular
* expression that matches if and only if any of the globs in the list
* matches.
* @param {!Array<string>} globs
* @return {!RegExp}
* @private
*/
static globsToRegExp_(globs) {
return new RegExp(
'^(' +
globs
.map(
glob => glob.replace(/[.+^$(){}|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.'))
.join('|') +
')$');
}
}
InstanceChecker.closeExtraInstances();
ChromeVoxState.instance = new Background();