@difizen/mana-core
Version:
456 lines (412 loc) • 13.8 kB
text/typescript
import type { Event } from '@difizen/mana-common';
import { Deferred, Emitter } from '@difizen/mana-common';
import { isOSX } from '@difizen/mana-common';
import { inject, postConstruct, singleton } from '@difizen/mana-syringe';
import { LocalStorageService } from '../common';
import {
KeyboardLayoutProvider,
KeyboardLayoutChangeNotifier,
} from './keyboard-protocol';
import type {
KeyValidationInput,
KeyValidator,
NativeKeyboardLayout,
} from './keyboard-protocol';
import { layoutRawDataLoader, en_US_mac, en_US_pc } from './layouts';
export type KeyboardLayoutSource =
| 'navigator.keyboard'
| 'user-choice'
| 'pressed-keys';
export class BrowserKeyboardLayoutProvider
implements KeyboardLayoutProvider, KeyboardLayoutChangeNotifier, KeyValidator
{
layoutDatas: KeyboardLayoutData[];
pendingLayoutMap: Map<string, Promise<KeyboardLayoutData>> = new Map();
protected tester: KeyboardTester;
protected readonly storageService: LocalStorageService;
constructor() {
// this.storageService = storageService;
this.layoutDatas = [
this.getLayoutData('en-US-pc', en_US_pc),
this.getLayoutData('en-US-mac', en_US_mac),
];
this.tester = new KeyboardTester(this.layoutDatas);
}
protected readonly initialized = new Deferred<void>();
protected readonly nativeLayoutChanged = new Emitter<NativeKeyboardLayout>();
get onDidChangeNativeLayout(): Event<NativeKeyboardLayout> {
return this.nativeLayoutChanged.event;
}
protected source: KeyboardLayoutSource = 'pressed-keys';
protected currentLayout: KeyboardLayoutData = DEFAULT_LAYOUT_DATA;
protected updateTester(): void {
this.tester = new KeyboardTester(this.layoutDatas);
}
/**
* Keyboard layout files are expected to have the following name scheme:
* `language-name-hardware.json`
*
* - `language`: A language subtag according to IETF BCP 47
* - `name`: Display name of the keyboard layout (without dashes)
* - `hardware`: `pc` or `mac`
*/
protected getLayoutData(
layoutId: string,
raw: NativeKeyboardLayout,
): KeyboardLayoutData {
const [language, name, hardware] = layoutId.split('-');
return {
name: name.replace('_', ' '),
hardware: hardware as 'pc' | 'mac',
language,
// Webpack knows what to do here and it should bundle all files under `../../../src/common/keyboard/layouts/`
raw: raw,
};
}
protected async loadLayout(layoutId: string): Promise<KeyboardLayoutData> {
const loader = layoutRawDataLoader[layoutId];
return this.getLayoutData(layoutId, await loader());
}
async addLayout(layoutId: string) {
if (this.layoutDatas.find((layout) => getLayoutId(layout) === layoutId)) {
return;
}
if (!this.pendingLayoutMap.has(layoutId)) {
this.pendingLayoutMap.set(layoutId, this.loadLayout(layoutId));
}
const layout = await this.pendingLayoutMap.get(layoutId);
if (layout) {
this.layoutDatas.push(layout);
this.updateTester();
}
}
get allLayoutData(): KeyboardLayoutData[] {
return this.tester.candidates.slice();
}
get currentLayoutData(): KeyboardLayoutData {
return this.currentLayout;
}
get currentLayoutSource(): KeyboardLayoutSource {
return this.source;
}
protected async initialize(): Promise<void> {
// await this.loadState();
const keyboard = (navigator as NavigatorExtension).keyboard;
if (keyboard && keyboard.addEventListener) {
keyboard.addEventListener('layoutchange', async () => {
const newLayout = await this.getNativeLayout();
this.nativeLayoutChanged.fire(newLayout);
});
}
this.initialized.resolve();
}
async getNativeLayout(): Promise<NativeKeyboardLayout> {
await this.initialized.promise;
if (this.source === 'user-choice') {
return this.currentLayout.raw;
}
const [layout, source] = await this.autodetect();
this.setCurrent(layout, source);
return layout.raw;
}
/**
* Set user-chosen keyboard layout data.
*/
async setLayoutData(
layout: KeyboardLayoutData | 'autodetect',
): Promise<KeyboardLayoutData> {
if (layout === 'autodetect') {
if (this.source === 'user-choice') {
const [newLayout, source] = await this.autodetect();
this.setCurrent(newLayout, source);
this.nativeLayoutChanged.fire(newLayout.raw);
return newLayout;
}
return this.currentLayout;
} else {
if (this.source !== 'user-choice' || layout !== this.currentLayout) {
this.setCurrent(layout, 'user-choice');
this.nativeLayoutChanged.fire(layout.raw);
}
return layout;
}
}
/**
* Test all known keyboard layouts with the given combination of pressed key and
* produced character. Matching layouts have their score increased (see class
* KeyboardTester). If this leads to a change of the top-scoring layout, a layout
* change event is fired.
*/
validateKey(keyCode: KeyValidationInput): void {
if (this.source !== 'pressed-keys') {
return;
}
const accepted = this.tester.updateScores(keyCode);
if (!accepted) {
return;
}
const layout = this.selectLayout();
if (layout !== this.currentLayout && layout !== DEFAULT_LAYOUT_DATA) {
this.setCurrent(layout, 'pressed-keys');
this.nativeLayoutChanged.fire(layout.raw);
}
}
protected setCurrent(layout: KeyboardLayoutData, source: KeyboardLayoutSource): void {
this.currentLayout = layout;
this.source = source;
// this.saveState();
if (
this.tester.inputCount &&
(source === 'pressed-keys' || source === 'navigator.keyboard')
) {
const from = source === 'pressed-keys' ? 'pressed keys' : 'browser API';
const hardware = layout.hardware === 'mac' ? 'Mac' : 'PC';
console.warn(
`Detected keyboard layout from ${from}: ${layout.name} (${hardware})`,
);
}
}
protected async autodetect(): Promise<[KeyboardLayoutData, KeyboardLayoutSource]> {
const keyboard = (navigator as NavigatorExtension).keyboard;
if (keyboard && keyboard.getLayoutMap) {
try {
const layoutMap = await keyboard.getLayoutMap();
this.testLayoutMap(layoutMap);
return [this.selectLayout(), 'navigator.keyboard'];
} catch (error) {
console.warn('Failed to obtain keyboard layout map.', error);
}
}
return [this.selectLayout(), 'pressed-keys'];
}
/**
* @param layoutMap a keyboard layout map according to https://wicg.github.io/keyboard-map/
*/
protected testLayoutMap(layoutMap: KeyboardLayoutMap): void {
this.tester.reset();
for (const [code, key] of layoutMap.entries()) {
this.tester.updateScores({ code, character: key });
}
}
/**
* Select a layout based on the current tester state and the operating system
* and language detected from the browser.
*/
protected selectLayout(): KeyboardLayoutData {
const candidates = this.tester.candidates;
const scores = this.tester.scores;
const topScore = this.tester.topScore;
const language = navigator.language;
let matchingOScount = 0;
let topScoringCount = 0;
for (let i = 0; i < candidates.length; i++) {
if (scores[i] === topScore) {
const candidate = candidates[i];
if (osMatches(candidate.hardware)) {
if (language && language.startsWith(candidate.language)) {
return candidate;
}
matchingOScount++;
}
topScoringCount++;
}
}
if (matchingOScount >= 1) {
return candidates.find(
(c, i) => scores[i] === topScore && osMatches(c.hardware),
)!;
}
if (topScoringCount >= 1) {
return candidates.find((_, i) => scores[i] === topScore)!;
}
return DEFAULT_LAYOUT_DATA;
}
protected async saveState(): Promise<void> {
const data: LayoutProviderState = {
tester: this.tester.getState(),
source: this.source,
currentLayout:
this.currentLayout !== DEFAULT_LAYOUT_DATA
? getLayoutId(this.currentLayout)
: undefined,
};
return this.storageService.setData('keyboard', data);
}
protected async loadState(): Promise<void> {
const data = await this.storageService.getData<LayoutProviderState>('keyboard');
if (data) {
this.tester.setState(data.tester || {});
this.source = data.source || 'pressed-keys';
if (data.currentLayout) {
const layout = this.tester.candidates.find(
(c) => getLayoutId(c) === data.currentLayout,
);
if (layout) {
this.currentLayout = layout;
}
} else {
this.currentLayout = DEFAULT_LAYOUT_DATA;
}
}
}
}
export interface KeyboardLayoutData {
name: string;
hardware: 'pc' | 'mac';
language: string;
raw: NativeKeyboardLayout;
}
function osMatches(hardware: 'pc' | 'mac'): boolean {
return isOSX ? hardware === 'mac' : hardware === 'pc';
}
/**
* This is the fallback keyboard layout selected when nothing else matches.
* It has an empty mapping, so user inputs are handled like with a standard US keyboard.
*/
export const DEFAULT_LAYOUT_DATA: KeyboardLayoutData = {
name: 'US',
hardware: isOSX ? 'mac' : 'pc',
language: 'en',
raw: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info: {} as any,
mapping: {},
},
};
export interface LayoutProviderState {
tester?: KeyboardTesterState;
source?: KeyboardLayoutSource;
currentLayout?: string | undefined;
}
export interface KeyboardTesterState {
scores?: Record<string, number>;
topScore?: number;
testedInputs?: Record<string, string>;
}
/**
* Holds score values for all known keyboard layouts. Scores are updated
* by comparing key codes with the corresponding character produced by
* the user's keyboard.
*/
export class KeyboardTester {
readonly scores: number[];
topScore = 0;
private readonly testedInputs = new Map<string, string>();
readonly candidates: KeyboardLayoutData[];
get inputCount(): number {
return this.testedInputs.size;
}
constructor(candidates: KeyboardLayoutData[]) {
this.candidates = candidates;
this.scores = this.candidates.map(() => 0);
}
reset(): void {
for (let i = 0; i < this.scores.length; i++) {
this.scores[i] = 0;
}
this.topScore = 0;
this.testedInputs.clear();
}
updateScores(input: KeyValidationInput): boolean {
let property: 'value' | 'withShift' | 'withAltGr' | 'withShiftAltGr';
if (input.shiftKey && input.altKey) {
property = 'withShiftAltGr';
} else if (input.shiftKey) {
property = 'withShift';
} else if (input.altKey) {
property = 'withAltGr';
} else {
property = 'value';
}
const inputKey = `${input.code}.${property}`;
if (this.testedInputs.has(inputKey)) {
if (this.testedInputs.get(inputKey) === input.character) {
return false;
} else {
// The same input keystroke leads to a different character:
// probably a keyboard layout change, so forget all previous scores
this.reset();
}
}
const scores = this.scores;
for (let i = 0; i < this.candidates.length; i++) {
scores[i] += this.testCandidate(this.candidates[i], input, property);
if (scores[i] > this.topScore) {
this.topScore = scores[i];
}
}
this.testedInputs.set(inputKey, input.character);
return true;
}
protected testCandidate(
candidate: KeyboardLayoutData,
input: KeyValidationInput,
property: 'value' | 'withShift' | 'withAltGr' | 'withShiftAltGr',
): number {
const keyMapping = candidate.raw.mapping[input.code];
if (keyMapping && keyMapping[property]) {
return keyMapping[property] === input.character ? 1 : 0;
} else {
return 0;
}
}
getState(): KeyboardTesterState {
const scores: Record<string, number> = {};
for (let i = 0; i < this.scores.length; i++) {
scores[getLayoutId(this.candidates[i])] = this.scores[i];
}
const testedInputs: Record<string, string> = {};
for (const [key, character] of this.testedInputs.entries()) {
testedInputs[key] = character;
}
return {
scores,
topScore: this.topScore,
testedInputs,
};
}
setState(state: KeyboardTesterState): void {
this.reset();
if (state.scores) {
const layoutIds = this.candidates.map(getLayoutId);
for (const id in state.scores) {
if (Object.prototype.hasOwnProperty.call(state.scores, id)) {
const index = layoutIds.indexOf(id);
if (index > 0) {
this.scores[index] = state.scores[id];
}
}
}
}
if (state.topScore) {
this.topScore = state.topScore;
}
if (state.testedInputs) {
for (const key in state.testedInputs) {
if (Object.prototype.hasOwnProperty.call(state.testedInputs, key)) {
this.testedInputs.set(key, state.testedInputs[key]);
}
}
}
}
}
/**
* API specified by https://wicg.github.io/keyboard-map/
*/
interface NavigatorExtension extends Navigator {
keyboard: Keyboard;
}
interface Keyboard {
getLayoutMap: () => Promise<KeyboardLayoutMap>;
addEventListener: (
type: 'layoutchange',
listener: EventListenerOrEventListenerObject,
) => void;
}
type KeyboardLayoutMap = Map<string, string>;
function getLayoutId(layout: KeyboardLayoutData): string {
return `${layout.language}-${layout.name.replace(' ', '_')}-${layout.hardware}`;
}