blob: 54b958b45fb5fb5127728f5ff1891c866e7b3426 [file] [log] [blame]
// Copyright 2020 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.
/**
* @fileoverview 'os-settings-input-page' is the input sub-page
* for language and input method settings.
*/
import 'chrome://resources/cr_components/localized_link/localized_link.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './add_input_methods_dialog.js';
import './add_spellcheck_languages_dialog.js';
import './os_edit_dictionary_page.js';
import '../keyboard_shortcut_banner/keyboard_shortcut_banner.js';
import '../../controls/settings_toggle_button.js';
import '../../settings_shared.css.js';
import '../../settings_page/settings_animated_pages.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {focusWithoutInk} from 'chrome://resources/js/cr/ui/focus_without_ink.m.js';
import {I18nBehavior, I18nBehaviorInterface} from 'chrome://resources/js/i18n_behavior.m.js';
import {html, mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {loadTimeData} from '../../i18n_setup.js';
import {Setting} from '../../mojom-webui/setting.mojom-webui.js';
import {Route, Router} from '../../router.js';
import {DeepLinkingBehavior, DeepLinkingBehaviorInterface} from '../deep_linking_behavior.js';
import {recordSettingChange} from '../metrics_recorder.js';
import {routes} from '../os_route.js';
import {PrefsBehavior, PrefsBehaviorInterface} from '../prefs_behavior.js';
import {RouteObserverBehavior, RouteObserverBehaviorInterface} from '../route_observer_behavior.js';
import {hasOptionsPageInSettings} from './input_method_util.js';
import {InputsShortcutReminderState, LanguagesMetricsProxy, LanguagesMetricsProxyImpl, LanguagesPageInteraction} from './languages_metrics_proxy.js';
import {LanguageHelper, LanguagesModel, LanguageState, SpellCheckLanguageState} from './languages_types.js';
/**
* @constructor
* @extends {PolymerElement}
* @implements {DeepLinkingBehaviorInterface}
* @implements {I18nBehaviorInterface}
* @implements {PrefsBehaviorInterface}
* @implements {RouteObserverBehaviorInterface}
*/
const OsSettingsInputPageElementBase = mixinBehaviors(
[DeepLinkingBehavior, I18nBehavior, PrefsBehavior, RouteObserverBehavior],
PolymerElement);
/** @polymer */
class OsSettingsInputPageElement extends OsSettingsInputPageElementBase {
static get is() {
return 'os-settings-input-page';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/* Preferences state. */
prefs: {
type: Object,
notify: true,
},
/** @type {!Map<string, (string|Function)>} */
focusConfig: {
type: Object,
observer: 'focusConfigChanged_',
},
/**
* Read-only reference to the languages model provided by the
* 'os-settings-languages' instance.
* @type {!LanguagesModel|undefined}
*/
languages: {
type: Object,
},
/** @type {!LanguageHelper} */
languageHelper: Object,
/**
* @private {!Array<!LanguageState|!SpellCheckLanguageState>|undefined}
*/
spellCheckLanguages_: {
type: Array,
computed: `getSpellCheckLanguages_(languageSettingsV2Update2Enabled_,
languages.spellCheckOnLanguages.*, languages.enabled.*)`,
},
/** @private */
showAddInputMethodsDialog_: {
type: Boolean,
value: false,
},
/** @private */
showAddSpellcheckLanguagesDialog_: {
type: Boolean,
value: false,
},
/**
* Used by DeepLinkingBehavior to focus this page's deep links.
* @type {!Set<!Setting>}
*/
supportedSettingIds: {
type: Object,
value: () => new Set([
Setting.kShowInputOptionsInShelf,
Setting.kAddInputMethod,
Setting.kSpellCheck,
]),
},
/** @private */
languageSettingsV2Update2Enabled_: {
type: Boolean,
value() {
return loadTimeData.getBoolean('enableLanguageSettingsV2Update2');
},
},
/** @private */
shouldShowLanguagePacksNotice_: {
type: Boolean,
value() {
return loadTimeData.getBoolean('languagePacksHandwritingEnabled');
},
},
/**
* Whether the shortcut reminder for the last used IME is currently
* showing.
* @private
*/
showLastUsedIMEShortcutReminder_: {
type: Boolean,
computed: `shouldShowLastUsedIMEShortcutReminder_(
languages.inputMethods.enabled.length,
prefs.ash.shortcut_reminders.last_used_ime_dismissed.value)`,
},
/**
* Whether the shortcut reminder for the next IME is currently showing.
* @private
*/
showNextIMEShortcutReminder_: {
type: Boolean,
computed: `shouldShowNextIMEShortcutReminder_(
languages.inputMethods.enabled.length,
prefs.ash.shortcut_reminders.next_ime_dismissed.value)`,
},
/**
* The body of the currently showing shortcut reminders.
* @private {!Array<string>}
*/
shortcutReminderBody_: {
type: Array,
computed: `getShortcutReminderBody_(showLastUsedIMEShortcutReminder_,
showNextIMEShortcutReminder_)`,
},
/** @private */
onDeviceGrammarCheckEnabled_: {
type: Boolean,
value() {
return loadTimeData.getBoolean('onDeviceGrammarCheckEnabled');
},
},
};
}
/** @override */
constructor() {
super();
/** @private {!LanguagesMetricsProxy} */
this.languagesMetricsProxy_ = LanguagesMetricsProxyImpl.getInstance();
}
/**
* @param {!Route} route
* @param {!Route=} oldRoute
*/
currentRouteChanged(route, oldRoute) {
// Does not apply to this page.
if (route !== routes.OS_LANGUAGES_INPUT) {
return;
}
this.attemptDeepLink();
}
/**
* @param {!Map<string, (string|Function)>} newConfig
* @param {?Map<string, (string|Function)>} oldConfig
* @private
*/
focusConfigChanged_(newConfig, oldConfig) {
// focusConfig is set only once on the parent, so this observer should
// only fire once.
assert(!oldConfig);
this.focusConfig.set(
routes.OS_LANGUAGES_EDIT_DICTIONARY.path,
() => focusWithoutInk(this.$.editDictionarySubpageTrigger));
}
/**
* @param {!Event} e
* @private
*/
onShowImeMenuChange_(e) {
this.languagesMetricsProxy_.recordToggleShowInputOptionsOnShelf(
e.target.checked);
}
/**
* @return {boolean}
* @private
*/
inputMethodsLimitedByPolicy_() {
const allowedInputMethodsPref =
this.getPref('settings.language.allowed_input_methods');
return !!allowedInputMethodsPref && allowedInputMethodsPref.value.length;
}
/**
* Handler for click events on an input method on the main page,
* which sets it as the current input method.
* @param {!{model: !{item: !chrome.languageSettingsPrivate.InputMethod},
* target: !{tagName: string}}} e
* @private
*/
onInputMethodClick_(e) {
// Clicks on the button are handled in onInputMethodOptionsClick_.
if (e.target.tagName === 'CR-ICON-BUTTON') {
return;
}
this.languageHelper.setCurrentInputMethod(e.model.item.id);
this.languagesMetricsProxy_.recordInteraction(
LanguagesPageInteraction.SWITCH_INPUT_METHOD);
recordSettingChange();
}
/**
* Handler for <Enter> events on an input method on the main page,
* which sets it as the current input method.
* @param {!{model: !{item: !chrome.languageSettingsPrivate.InputMethod},
* key: string}} e
* @private
*/
onInputMethodKeyPress_(e) {
// Ignores key presses other than <Enter>.
if (e.key !== 'Enter') {
return;
}
this.languageHelper.setCurrentInputMethod(e.model.item.id);
}
/**
* Opens the input method extension's options page in a new tab (or focuses
* an existing instance of the IME's options).
* @param {!{model: !{item: chrome.languageSettingsPrivate.InputMethod}}} e
* @private
*/
openExtensionOptionsPage_(e) {
this.languageHelper.openInputMethodOptions(e.model.item.id);
}
/**
* @param {string} id The input method ID.
* @return {boolean} True if there is a options page in ChromeOS settings
* for the input method ID.
* @private
*/
hasOptionsPageInSettings_(id) {
return hasOptionsPageInSettings(
id, loadTimeData.getBoolean('allowPredictiveWriting'),
loadTimeData.getBoolean('allowDiacriticsOnPhysicalKeyboardLongpress'));
}
/**
* @param {!{model: !{item: chrome.languageSettingsPrivate.InputMethod}}} e
* @private
*/
navigateToOptionsPageInSettings_(e) {
const params = new URLSearchParams();
params.append('id', e.model.item.id);
Router.getInstance().navigateTo(
routes.OS_LANGUAGES_INPUT_METHOD_OPTIONS, params);
}
/**
* @param {string} id The input method ID.
* @param {string} currentId The ID of the currently enabled input method.
* @return {boolean} True if the IDs match.
* @private
*/
isCurrentInputMethod_(id, currentId) {
return id === currentId;
}
/**
* @param {string} id The input method ID.
* @param {string} currentId The ID of the currently enabled input method.
* @return {string} The class for the input method item.
* @private
*/
getInputMethodItemClass_(id, currentId) {
return this.isCurrentInputMethod_(id, currentId) ? 'selected' : '';
}
/**
* @param {string} id The selected input method ID.
* @param {string} currentId The ID of the currently enabled input method.
* @return {string} The default tab index '0' if the selected input method
* is not currently enabled; otherwise, returns an empty string which
* effectively unsets the tabindex attribute.
* @private
*/
getInputMethodTabIndex_(id, currentId) {
return id === currentId ? '' : '0';
}
/**
* @param {string} inputMethodName
* @return {string}
* @private
*/
getOpenOptionsPageLabel_(inputMethodName) {
return this.i18n('openOptionsPage', inputMethodName);
}
/** @private */
onAddInputMethodClick_() {
this.languagesMetricsProxy_.recordAddInputMethod();
this.showAddInputMethodsDialog_ = true;
}
/** @private */
onAddInputMethodsDialogClose_() {
this.showAddInputMethodsDialog_ = false;
focusWithoutInk(assert(this.$.addInputMethod));
}
/** @private */
onAddSpellcheckLanguagesClick_() {
this.showAddSpellcheckLanguagesDialog_ = true;
}
/** @private */
onAddSpellcheckLanguagesDialogClose_() {
this.showAddSpellcheckLanguagesDialog_ = false;
if (this.languages.spellCheckOnLanguages.length > 0) {
// User has at least one spell check language after closing the dialog.
// If spell checking is disabled, enabled it.
this.setPrefValue('browser.enable_spellchecking', true);
}
// Because #addSpellcheckLanguages is not statically created (as it is
// within a <template is="dom-if">), we need to use
// this.shadowRoot.querySelector("#addSpellcheckLanguages") instead of
// this.$.addSpellCheckLanguages.
focusWithoutInk(
assert(this.shadowRoot.querySelector('#addSpellcheckLanguages')));
}
/**
* @param {!chrome.languageSettingsPrivate.InputMethod} targetInputMethod
* @private
*/
disableRemoveInputMethod_(targetInputMethod) {
// Third-party IMEs can always be removed.
if (!this.languageHelper.isComponentIme(targetInputMethod)) {
return false;
}
// Disable remove if there is no other component IME (pre-installed
// system IMES) enabled.
return !this.languages.inputMethods.enabled.some(
inputMethod => inputMethod.id !== targetInputMethod.id &&
this.languageHelper.isComponentIme(inputMethod));
}
/**
* @param {!chrome.languageSettingsPrivate.InputMethod} inputMethod
* @private
*/
getRemoveInputMethodTooltip_(inputMethod) {
return this.i18n('removeInputMethodTooltip', inputMethod.displayName);
}
/**
* @param {!{model: !{item: chrome.languageSettingsPrivate.InputMethod}}} e
* @private
*/
onRemoveInputMethodClick_(e) {
this.languageHelper.removeInputMethod(e.model.item.id);
recordSettingChange();
}
/**
* @param {!SpellCheckLanguageState} lang
* @private
*/
getRemoveSpellcheckLanguageTooltip_(lang) {
return this.i18n(
'removeSpellCheckLanguageTooltip', lang.language.displayName);
}
/**
* @param {!{model: !{item: SpellCheckLanguageState}}} e
* @private
*/
onRemoveSpellcheckLanguageClick_(e) {
this.languageHelper.toggleSpellCheck(e.model.item.language.code, false);
recordSettingChange();
}
/**
* Called whenever the spell check toggle is changed by the user.
* @param {!Event} e
* @private
*/
onSpellcheckToggleChange_(e) {
const toggle = /** @type {SettingsToggleButtonElement} */ (e.target);
this.languagesMetricsProxy_.recordToggleSpellCheck(toggle.checked);
if (this.languageSettingsV2Update2Enabled_ && toggle.checked &&
this.languages.spellCheckOnLanguages.length === 0) {
// In LSV2 Update 2, we never want to enable spell check without the user
// having a spell check language. When this happens, we try estimating
// their expected spell check language (their device language, assuming
// that the user has an input method which supports that language).
// If that doesn't work, we fall back on prompting the user to enable a
// spell check language and immediately disable spell check before this
// happens. If the user then adds a spell check language, we finally
// enable spell check (see |onAddSpellcheckLanguagesDialogClose_|).
// This assert is safe as prospectiveUILanguage is always defined in
// languages.js' |createModel_()|.
const deviceLanguageCode = assert(this.languages.prospectiveUILanguage);
// However, deviceLanguage itself may be undefined as it is possible that
// it was set outside of CrOS language settings (normally when debugging
// or in tests).
const deviceLanguage =
this.languageHelper.getLanguage(deviceLanguageCode);
if (deviceLanguage && deviceLanguage.supportsSpellcheck &&
this.languages.inputMethods.enabled.some(
inputMethod =>
inputMethod.languageCodes.includes(deviceLanguageCode))) {
this.languageHelper.toggleSpellCheck(deviceLanguageCode, true);
} else {
this.onAddSpellcheckLanguagesClick_();
// "Undo" the toggle change by reverting it back to the original pref
// value. The toggle will be flipped on once the user finishes adding
// a spell check language.
toggle.resetToPrefValue();
// We don't need to commit the pref change below, so early return.
return;
}
}
// Manually commit the pref change as we've set noSetPref on the toggle
// button.
toggle.sendPrefChange();
}
/**
* Returns the value to use as the |pref| attribute for the policy indicator
* of spellcheck languages, based on whether or not the language is enabled.
* @param {boolean} isEnabled Whether the language is enabled or not.
* @private
*/
getIndicatorPrefForManagedSpellcheckLanguage_(isEnabled) {
return isEnabled ? this.getPref('spellcheck.forced_dictionaries') :
this.getPref('spellcheck.blocked_dictionaries');
}
/**
* Returns an array of spell check languages for the UI.
* @return {!Array<!LanguageState|!SpellCheckLanguageState>|undefined}
* @private
*/
getSpellCheckLanguages_() {
if (this.languages === undefined) {
return undefined;
}
if (this.languageSettingsV2Update2Enabled_) {
const languages = [...this.languages.spellCheckOnLanguages];
languages.sort(
(a, b) =>
a.language.displayName.localeCompare(b.language.displayName));
return languages;
}
const combinedLanguages =
this.languages.enabled.concat(this.languages.spellCheckOnLanguages);
const supportedSpellcheckLanguagesSet = new Set();
const supportedSpellcheckLanguages = [];
combinedLanguages.forEach(languageState => {
if (!supportedSpellcheckLanguagesSet.has(languageState.language.code) &&
languageState.language.supportsSpellcheck) {
supportedSpellcheckLanguages.push(languageState);
supportedSpellcheckLanguagesSet.add(languageState.language.code);
}
});
return supportedSpellcheckLanguages;
}
/**
* Handler for enabling or disabling spell check for a specific language.
* @param {!{target: Element, model: !{item: !LanguageState}}} e
* @private
*/
onSpellCheckLanguageChange_(e) {
const item = e.model.item;
if (!item.language.supportsSpellcheck) {
return;
}
this.languageHelper.toggleSpellCheck(
item.language.code, !item.spellCheckEnabled);
}
/**
* Handler for clicking on the name of the language. The action taken must
* match the control that is available.
* @param {!{target: Element, model: !{item: !LanguageState}}} e
* @private
*/
onSpellCheckNameClick_(e) {
assert(!this.isSpellCheckNameClickDisabled_(e.model.item));
this.onSpellCheckLanguageChange_(e);
}
/**
* Name only supports clicking when language is not managed, supports
* spellcheck, and the dictionary has been downloaded with no errors.
* @param {!LanguageState|!SpellCheckLanguageState} item
* @return {boolean}
* @private
*/
isSpellCheckNameClickDisabled_(item) {
return item.isManaged || item.downloadDictionaryFailureCount > 0 ||
!this.getPref('browser.enable_spellchecking').value;
}
/**
* Handler to initiate another attempt at downloading the spell check
* dictionary for a specified language.
* @param {!{target: Element, model: !{item: !LanguageState}}} e
* @private
*/
onRetryDictionaryDownloadClick_(e) {
assert(e.model.item.downloadDictionaryFailureCount > 0);
this.languageHelper.retryDownloadDictionary(e.model.item.language.code);
}
/**
* @param {!LanguageState} item
* @return {!string}
* @private
*/
getDictionaryDownloadRetryAriaLabel_(item) {
return this.i18n(
'languagesDictionaryDownloadRetryDescription',
item.language.displayName);
}
/**
* Opens the Custom Dictionary page.
* @private
*/
onEditDictionaryClick_() {
this.languagesMetricsProxy_.recordInteraction(
LanguagesPageInteraction.OPEN_CUSTOM_SPELL_CHECK);
Router.getInstance().navigateTo(routes.OS_LANGUAGES_EDIT_DICTIONARY);
}
/**
* Gets the appropriate CSS class for the Enhanced spell check toggle
* depending on whether Update 2 is enabled or not.
* @private
*/
getEnhancedSpellCheckClass_() {
return this.languageSettingsV2Update2Enabled_ ? '' : 'hr';
}
/**
* @private
*/
isEnableSpellcheckingDisabled_() {
return !this.languageSettingsV2Update2Enabled_ &&
(this.spellCheckLanguages_ && this.spellCheckLanguages_.length === 0);
}
/**
* @param {boolean} update2Enabled
* @param {boolean} spellCheckOn
* @return {boolean}
*/
isCollapseOpened_(update2Enabled, spellCheckOn) {
return !update2Enabled || spellCheckOn;
}
/** @private */
onLanguagePackNoticeLinkClick_() {
this.languagesMetricsProxy_.recordInteraction(
LanguagesPageInteraction.OPEN_LANGUAGE_PACKS_LEARN_MORE);
}
/**
* @return {boolean}
* @private
*/
shouldShowLastUsedIMEShortcutReminder_() {
// User has already dismissed the shortcut reminder.
if (this.getPref('ash.shortcut_reminders.last_used_ime_dismissed').value) {
return false;
}
// Need at least 2 input methods to be shown the reminder.
return !!this.languages && this.languages.inputMethods.enabled.length >= 2;
}
/**
* @return {boolean}
* @private
*/
shouldShowNextIMEShortcutReminder_() {
// User has already dismissed the shortcut reminder.
if (this.getPref('ash.shortcut_reminders.next_ime_dismissed').value) {
return false;
}
// Need at least 3 input methods to be shown the reminder.
return !!this.languages && this.languages.inputMethods.enabled.length >= 3;
}
/**
* @return {!Array<string>}
* @private
*/
getShortcutReminderBody_() {
const /** !Array<string> */ reminderBody = [];
if (this.showLastUsedIMEShortcutReminder_) {
reminderBody.push(this.i18nAdvanced('imeShortcutReminderLastUsed'));
}
if (this.showNextIMEShortcutReminder_) {
reminderBody.push(this.i18nAdvanced('imeShortcutReminderNext'));
}
return reminderBody;
}
/**
* @return {boolean}
* @private
*/
shouldShowShortcutReminder_() {
return this.languageSettingsV2Update2Enabled_ &&
this.shortcutReminderBody_ && this.shortcutReminderBody_.length > 0;
}
/** @private */
onShortcutReminderDismiss_() {
// Record the metric - assume that both reminders were dismissed unless one
// of them wasn't shown.
assert(
this.showLastUsedIMEShortcutReminder_ ||
this.showNextIMEShortcutReminder_);
let dismissedState = InputsShortcutReminderState.LAST_USED_IME_AND_NEXT_IME;
if (!this.showLastUsedIMEShortcutReminder_) {
dismissedState = InputsShortcutReminderState.NEXT_IME;
} else if (!this.showNextIMEShortcutReminder_) {
dismissedState = InputsShortcutReminderState.LAST_USED_IME;
}
this.languagesMetricsProxy_.recordShortcutReminderDismissed(dismissedState);
if (this.showLastUsedIMEShortcutReminder_) {
this.setPrefValue('ash.shortcut_reminders.last_used_ime_dismissed', true);
}
if (this.showNextIMEShortcutReminder_) {
this.setPrefValue('ash.shortcut_reminders.next_ime_dismissed', true);
}
}
}
customElements.define(
OsSettingsInputPageElement.is, OsSettingsInputPageElement);