Let a text field select from right to left with the mouse (#236)
Dragging from right to left in a canvas text field only moved the cursor. The hidden input behind the field got every selection sorted and without a direction. Its selectionchange echo then handed back the sorted range, and the anchor jumped to the mouse on the first move. - KeyboardBridge sets the selection with 'backward' when the focus lies before the anchor, and reads selectionDirection when the input reports a change. - TextField passes anchor and focus through unchanged. Its own sorting is gone. - Shift+Left runs through the same path. The cursor should now sit at the moving end there too. This is not tested on its own. Tests: - ./check.sh: 167 files, 2435 tests, CHECK EXIT 0 - e2e 2: ALL PASSED, 25 checks. New check 2d-ii drags right to left in the name field and reads start 0, end 13, direction backward. - Counter-check without the direction: 2d-ii fails with end 2, the reported symptom. - e2e check total in README goes to 1401 (computed, not run in full). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74b9c60661
commit
1b3cb625a3
4 files changed
+43
-23
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1400 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1401 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+19
-12
@@ -5,7 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TextEditTarget {
|
export interface TextEditTarget {
|
||||||
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void;
|
/** `anchor` is where the selection started, `focus` where the caret is; focus < anchor is backward. */
|
||||||
|
onBridgeInput(value: string, anchor: number, focus: number): void;
|
||||||
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
|
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
|
||||||
onBridgeKey(event: KeyboardEvent): boolean;
|
onBridgeKey(event: KeyboardEvent): boolean;
|
||||||
onBridgeBlur(): void;
|
onBridgeBlur(): void;
|
||||||
@@ -40,8 +41,14 @@ export class KeyboardBridge {
|
|||||||
document.body.appendChild(input);
|
document.body.appendChild(input);
|
||||||
this.input = input;
|
this.input = input;
|
||||||
|
|
||||||
const sync = () =>
|
// The input keeps its range ordered; the direction says which end is the anchor. Reporting
|
||||||
this.target?.onBridgeInput(input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0);
|
// the ordered range lost the anchor of a right-to-left drag on the first selectionchange.
|
||||||
|
const sync = () => {
|
||||||
|
const start = input.selectionStart ?? 0;
|
||||||
|
const end = input.selectionEnd ?? 0;
|
||||||
|
const backward = input.selectionDirection === 'backward';
|
||||||
|
this.target?.onBridgeInput(input.value, backward ? end : start, backward ? start : end);
|
||||||
|
};
|
||||||
input.addEventListener('input', sync);
|
input.addEventListener('input', sync);
|
||||||
input.addEventListener('keyup', sync);
|
input.addEventListener('keyup', sync);
|
||||||
document.addEventListener('selectionchange', () => {
|
document.addEventListener('selectionchange', () => {
|
||||||
@@ -62,17 +69,12 @@ export class KeyboardBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors the field's value into the input and grabs DOM focus. */
|
/** Mirrors the field's value into the input and grabs DOM focus. */
|
||||||
attach(
|
attach(target: TextEditTarget, value: string, anchor = value.length, focus = anchor): void {
|
||||||
target: TextEditTarget,
|
|
||||||
value: string,
|
|
||||||
selectionStart = value.length,
|
|
||||||
selectionEnd = selectionStart,
|
|
||||||
): void {
|
|
||||||
this.suppressBlur = true;
|
this.suppressBlur = true;
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.input.value = value;
|
this.input.value = value;
|
||||||
this.input.focus({ preventScroll: true });
|
this.input.focus({ preventScroll: true });
|
||||||
this.input.setSelectionRange(selectionStart, selectionEnd);
|
this.select(anchor, focus);
|
||||||
this.suppressBlur = false;
|
this.suppressBlur = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +85,14 @@ export class KeyboardBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pushes a value the canvas side changed into the input, so the next keystroke edits that text. */
|
/** Pushes a value the canvas side changed into the input, so the next keystroke edits that text. */
|
||||||
setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
|
setValue(value: string, anchor = value.length, focus = anchor): void {
|
||||||
this.input.value = value;
|
this.input.value = value;
|
||||||
if (document.activeElement === this.input) this.input.setSelectionRange(selectionStart, selectionEnd);
|
if (document.activeElement === this.input) this.select(anchor, focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private select(anchor: number, focus: number): void {
|
||||||
|
const direction = focus < anchor ? 'backward' : 'forward';
|
||||||
|
this.input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ignored when another field attached meanwhile, so a late blur cannot steal it. */
|
/** Ignored when another field attached meanwhile, so a late blur cannot steal it. */
|
||||||
|
|||||||
@@ -167,23 +167,18 @@ export class TextField extends Widget implements TextEditTarget {
|
|||||||
private setSelection(start: number, end: number, pushToBridge: boolean): void {
|
private setSelection(start: number, end: number, pushToBridge: boolean): void {
|
||||||
this.selStart = clamp(start, 0, this.valueText.length);
|
this.selStart = clamp(start, 0, this.valueText.length);
|
||||||
this.selEnd = clamp(end, 0, this.valueText.length);
|
this.selEnd = clamp(end, 0, this.valueText.length);
|
||||||
if (pushToBridge && this.editing) {
|
// selStart/selEnd are anchor/focus; the bridge keeps that order across its selectionchange echo.
|
||||||
// The DOM only takes an ordered range; selStart/selEnd keep the anchor/focus order a
|
if (pushToBridge && this.editing) ui().keyboard.setValue(this.valueText, this.selStart, this.selEnd);
|
||||||
// drag selection needs.
|
|
||||||
const lo = Math.min(this.selStart, this.selEnd);
|
|
||||||
const hi = Math.max(this.selStart, this.selEnd);
|
|
||||||
ui().keyboard.setValue(this.valueText, lo, hi);
|
|
||||||
}
|
|
||||||
this.caretVisible = true;
|
this.caretVisible = true;
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adopts the input's value and selection verbatim; fires `onInput` only when the text changed. */
|
/** Adopts the input's value and selection verbatim; fires `onInput` only when the text changed. */
|
||||||
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void {
|
onBridgeInput(value: string, anchor: number, focus: number): void {
|
||||||
const changed = value !== this.valueText;
|
const changed = value !== this.valueText;
|
||||||
this.valueText = value;
|
this.valueText = value;
|
||||||
this.selStart = selectionStart;
|
this.selStart = anchor;
|
||||||
this.selEnd = selectionEnd;
|
this.selEnd = focus;
|
||||||
this.caretVisible = true;
|
this.caretVisible = true;
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
if (changed) this.onInput?.(value);
|
if (changed) this.onInput?.(value);
|
||||||
|
|||||||
@@ -105,6 +105,24 @@ export async function inspector(t) {
|
|||||||
JSON.stringify((await object('o2')).name),
|
JSON.stringify((await object('o2')).name),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2d-ii. A drag from right to left selects the whole name (#236). The hidden input reported its
|
||||||
|
// range ordered, and its selectionchange echo made the caret the anchor after the first move.
|
||||||
|
await click(nameLabel.x + 160, nameLabel.y);
|
||||||
|
const nameField = await json('__gm.focusedRect()');
|
||||||
|
await press('Escape');
|
||||||
|
const midY = nameField.y + nameField.h / 2;
|
||||||
|
await drag(nameField.x + nameField.w - 4, midY, nameField.x + 3, midY);
|
||||||
|
const backward = await json(
|
||||||
|
'({ start: document.activeElement?.selectionStart, end: document.activeElement?.selectionEnd,' +
|
||||||
|
' direction: document.activeElement?.selectionDirection })',
|
||||||
|
);
|
||||||
|
await press('Escape');
|
||||||
|
check(
|
||||||
|
'dragging from right to left selects the whole name',
|
||||||
|
backward.start === 0 && backward.end === 'Renamed crate'.length && backward.direction === 'backward',
|
||||||
|
JSON.stringify(backward),
|
||||||
|
);
|
||||||
|
|
||||||
// 2e. Scrub the X field (right of the "X" label) by 100 px.
|
// 2e. Scrub the X field (right of the "X" label) by 100 px.
|
||||||
const xLabel = await locate('X');
|
const xLabel = await locate('X');
|
||||||
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
|
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
|
||||||
|
|||||||
Reference in new issue
Block a user