Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

change(web): reworks nearest-key detection to avoid layout reflow 🪠 #11129

Merged
merged 3 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 73 additions & 67 deletions web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActiveLayer, type DeviceSpec, Keyboard, LayoutLayer, ActiveLayout } from '@keymanapp/keyboard-processor';
import { ActiveLayer, type DeviceSpec, Keyboard, LayoutLayer, ActiveLayout, ButtonClasses } from '@keymanapp/keyboard-processor';
import { ManagedPromise } from '@keymanapp/web-utils';

import { InputSample } from '@keymanapp/gesture-recognizer';
Expand All @@ -7,12 +7,19 @@ import { KeyElement } from '../keyElement.js';
import OSKLayer from './oskLayer.js';
import VisualKeyboard from '../visualKeyboard.js';
import OSKRow from './oskRow.js';
import OSKBaseKey from './oskBaseKey.js';

const NEAREST_KEY_TOUCH_MARGIN_PERCENT = 0.06;

export default class OSKLayerGroup {
public readonly element: HTMLDivElement;
public readonly layers: {[layerID: string]: OSKLayer} = {};
public readonly spec: ActiveLayout;

// Exist as local copies of the VisualKeyboard values, updated via refreshLayout.
private computedWidth: number;
private computedHeight: number;

private _activeLayerId: string = 'default';

public constructor(vkbd: VisualKeyboard, keyboard: Keyboard, formFactor: DeviceSpec.FormFactor) {
Expand Down Expand Up @@ -107,8 +114,6 @@ export default class OSKLayerGroup {
throw new Error(`Layer id ${layerId} could not be found`);
}

this.blinkLayer(layer);

return this.nearestKey(coord, layer);
}

Expand Down Expand Up @@ -165,86 +170,87 @@ export default class OSKLayerGroup {
}

private nearestKey(coord: Omit<InputSample<KeyElement>, 'item'>, layer: OSKLayer): KeyElement {
const baseRect = this.element.getBoundingClientRect();

let row: OSKRow = null;
let bestMatchDistance = Number.MAX_VALUE;
// If there are no rows, there are no keys; return instantly.
if(layer.rows.length == 0) {
return null;
}

// Find the row that the touch-coordinate lies within.
for(const r of layer.rows) {
const rowRect = r.element.getBoundingClientRect();
if(rowRect.top <= coord.clientY && coord.clientY < rowRect.bottom) {
row = r;
break;
} else {
const distance = rowRect.top > coord.clientY ? rowRect.top - coord.clientY : coord.clientY - rowRect.bottom;
// Our pre-processed layout info maps whatever shape the keyboard is in into a unit square.
// So, we map our coord to find its location within that square.
const proportionalCoords = {
x: coord.targetX / this.computedWidth,
y: coord.targetY / this.computedHeight
};

if(distance < bestMatchDistance) {
bestMatchDistance = distance;
row = r;
}
}
// If our computed width and/or height are 0, it's best to abort; key distance
// calculations are not viable.
if(!isFinite(proportionalCoords.x) || !isFinite(proportionalCoords.y)) {
return null;
}

// Assertion: row no longer `null`.
// Step 1: find the nearest row.
// Rows aren't variable-height - this value is "one size fits all."

// Warning: am not 100% sure that what follows is actually fully correct.
/*
If 4 rows, y = .2 x 4 = .8 - still within the row with index 0 (spanning from 0 to .25)
y = .6 x 4 = 2.4 - within row with index 2 (third row, spanning .5 to .75)

// Find minimum distance from any key
let closestKeyIndex = 0;
let dx: number;
let dxMax = 24;
let dxMin = 100000;
Assumes there is no fine-tuning of the row ranges to be done - each takes a perfect
fraction of the overall layer height without any padding above or below.
*/
const rowIndex = Math.floor(proportionalCoords.y * layer.rows.length);
const row = layer.rows[rowIndex];

const x = coord.clientX;

for (let k = 0; k < row.keys.length; k++) {
// Second-biggest, though documentation suggests this is probably right.
const keySquare = row.keys[k].square as HTMLElement; // gets the .kmw-key-square containing a key
const squareRect = keySquare.getBoundingClientRect();
// Assertion: row no longer `null`.
// (We already prevented the no-rows available scenario, anyway.)

// Find the actual key element.
let childNode = keySquare.firstChild ? keySquare.firstChild as HTMLElement : keySquare;
// Step 2: Find minimum distance from any key
// - If the coord is within a key's square, go ahead and return it.
let closestKey: OSKBaseKey = null;
// Is percentage-based!
let minDistance = Number.MAX_VALUE;

if (childNode.className !== undefined
&& (childNode.className.indexOf('key-hidden') >= 0
|| childNode.className.indexOf('key-blank') >= 0)) {
for (let key of row.keys) {
const keySpec = key.spec;
if(keySpec.sp == ButtonClasses.blank || keySpec.sp == ButtonClasses.spacer) {
continue;
}
const x1 = squareRect.left;
const x2 = squareRect.right;
if (x >= x1 && x <= x2) {
// Within the key square
return <KeyElement>childNode;
}
dx = x1 - x;
if (dx >= 0 && dx < dxMin) {
// To right of key
closestKeyIndex = k; dxMin = dx;
}
dx = x - x2;
if (dx >= 0 && dx < dxMin) {
// To left of key
closestKeyIndex = k; dxMin = dx;
}
}

if (dxMin < 100000) {
const t = <HTMLElement>row.keys[closestKeyIndex].square;
const squareRect = t.getBoundingClientRect();
// Max distance from the key's center to consider, horizontally.
const keyRadius = keySpec.proportionalWidth / 2;
const distanceFromCenter = Math.abs(proportionalCoords.x - keySpec.proportionalX);

const x1 = squareRect.left;
const x2 = squareRect.right;

// Limit extended touch area to the larger of 0.6 of key width and 24 px
if (squareRect.width > 40) {
dxMax = 0.6 * squareRect.width;
// Find the actual key element.
if(distanceFromCenter - keyRadius <= 0) {
// As noted above: if we land within a key's square, match instantly!
return key.btn;
} else {
const distance = distanceFromCenter - keyRadius;
if(distance < minDistance) {
minDistance = distance;
closestKey = key;
}
}
Comment on lines +228 to +232
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the distance starts to increase again, we can break out of the loop, right?

}

if (((x1 - x) >= 0 && (x1 - x) < dxMax) || ((x - x2) >= 0 && (x - x2) < dxMax)) {
return <KeyElement>t.firstChild;
}
/*
Step 3: If the input coordinate wasn't within any valid key's "square",
determine if the nearest valid key is acceptable - if it's within 60% of
a standard key's width from the touch location.

If the condition is not met, there are no valid keys within this row.
*/
if (minDistance /* %age-based! */ <= NEAREST_KEY_TOUCH_MARGIN_PERCENT) {
return closestKey.btn;
}

// Step 4: no matches => return null. The caller should be able to handle such cases,
// anyway.
return null;
}

public refreshLayout(computedWidth: number, computedHeight: number) {
this.computedWidth = computedWidth;
this.computedHeight = computedHeight;
}
}
2 changes: 2 additions & 0 deletions web/src/engine/osk/src/keyboard-layout/oskRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default class OSKRow {
public readonly element: HTMLDivElement;
public readonly keys: OSKBaseKey[];
public readonly heightFraction: number;
public readonly spec: ActiveRow;

public constructor(vkbd: VisualKeyboard,
layerSpec: ActiveLayer,
Expand All @@ -23,6 +24,7 @@ export default class OSKRow {

// Apply defaults, setting the width and other undefined properties for each key
const keys=rowSpec.key;
this.spec = rowSpec;
this.keys = [];

// Calculate actual key widths by multiplying by the OSK's width and rounding appropriately,
Expand Down
4 changes: 4 additions & 0 deletions web/src/engine/osk/src/visualKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,10 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
return;
}

// Set layer-group copies of the computed-size values; they are used by nearest-key
// detection.
this.layerGroup.refreshLayout(this._computedWidth, this._computedHeight);

// Step 3: recalculate gesture parameter values
// Skip for doc-keyboards, since they don't do gestures.
if(!this.isStatic) {
Expand Down
Loading