diff --git a/README.md b/README.md index b9b47087..931d5a43 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/ui/KeyboardBridge.ts b/src/ui/KeyboardBridge.ts index 60e4e7a4..187845af 100644 --- a/src/ui/KeyboardBridge.ts +++ b/src/ui/KeyboardBridge.ts @@ -5,7 +5,8 @@ */ 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. */ onBridgeKey(event: KeyboardEvent): boolean; onBridgeBlur(): void; @@ -40,8 +41,14 @@ export class KeyboardBridge { document.body.appendChild(input); this.input = input; - const sync = () => - this.target?.onBridgeInput(input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0); + // The input keeps its range ordered; the direction says which end is the anchor. Reporting + // 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('keyup', sync); document.addEventListener('selectionchange', () => { @@ -62,17 +69,12 @@ export class KeyboardBridge { } /** Mirrors the field's value into the input and grabs DOM focus. */ - attach( - target: TextEditTarget, - value: string, - selectionStart = value.length, - selectionEnd = selectionStart, - ): void { + attach(target: TextEditTarget, value: string, anchor = value.length, focus = anchor): void { this.suppressBlur = true; this.target = target; this.input.value = value; this.input.focus({ preventScroll: true }); - this.input.setSelectionRange(selectionStart, selectionEnd); + this.select(anchor, focus); 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. */ - setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void { + setValue(value: string, anchor = value.length, focus = anchor): void { 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. */ diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index 8566566c..a9e0b98d 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -167,23 +167,18 @@ export class TextField extends Widget implements TextEditTarget { private setSelection(start: number, end: number, pushToBridge: boolean): void { this.selStart = clamp(start, 0, this.valueText.length); this.selEnd = clamp(end, 0, this.valueText.length); - if (pushToBridge && this.editing) { - // The DOM only takes an ordered range; selStart/selEnd keep the anchor/focus order a - // 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); - } + // selStart/selEnd are anchor/focus; the bridge keeps that order across its selectionchange echo. + if (pushToBridge && this.editing) ui().keyboard.setValue(this.valueText, this.selStart, this.selEnd); this.caretVisible = true; this.invalidate(); } /** 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; this.valueText = value; - this.selStart = selectionStart; - this.selEnd = selectionEnd; + this.selStart = anchor; + this.selEnd = focus; this.caretVisible = true; this.invalidate(); if (changed) this.onInput?.(value); diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index ceefc5db..b9a4bf14 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -105,6 +105,24 @@ export async function inspector(t) { 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. const xLabel = await locate('X'); await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);