Refactor text editing helpers into input module

This commit is contained in:
Jage9
2026-02-21 03:57:49 -05:00
parent 92f4251cc1
commit 7d0b38ffa4
3 changed files with 182 additions and 158 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.21 R94";
window.CHGRID_WEB_VERSION = "2026.02.21 R95";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -28,3 +28,157 @@ export function applyTextInput(
return { newString, newCursorPos };
}
export function shouldReplaceCurrentText(
code: string,
key: string,
replaceTextOnNextType: boolean,
): { replaceTextOnNextType: boolean; shouldReplace: boolean } {
if (!replaceTextOnNextType) return { replaceTextOnNextType: false, shouldReplace: false };
if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') {
return { replaceTextOnNextType: false, shouldReplace: false };
}
if (code === 'Backspace' || code === 'Delete') {
return { replaceTextOnNextType: false, shouldReplace: false };
}
if (key.length === 1) {
return { replaceTextOnNextType: false, shouldReplace: true };
}
return { replaceTextOnNextType: true, shouldReplace: false };
}
export function normalizePastedText(raw: string): string {
return raw
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n/g, ' ')
.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '');
}
export function applyPastedText(
raw: string,
currentString: string,
cursorPos: number,
maxLength: number,
replaceTextOnNextType: boolean,
): { handled: boolean; newString: string; newCursorPos: number; replaceTextOnNextType: boolean } {
const text = normalizePastedText(raw);
if (!text) {
return { handled: true, newString: currentString, newCursorPos: cursorPos, replaceTextOnNextType };
}
if (replaceTextOnNextType) {
const replacement = text.slice(0, maxLength);
return {
handled: true,
newString: replacement,
newCursorPos: replacement.length,
replaceTextOnNextType: false,
};
}
const available = Math.max(0, maxLength - currentString.length);
if (available <= 0) {
return { handled: true, newString: currentString, newCursorPos: cursorPos, replaceTextOnNextType: false };
}
const insert = text.slice(0, available);
return {
handled: true,
newString: currentString.slice(0, cursorPos) + insert + currentString.slice(cursorPos),
newCursorPos: cursorPos + insert.length,
replaceTextOnNextType: false,
};
}
export function mapTextInputKey(code: string, key: string): string {
if (code === 'ArrowLeft') return 'arrowleft';
if (code === 'ArrowRight') return 'arrowright';
if (code === 'Backspace') return 'backspace';
if (code === 'Home') return 'home';
if (code === 'End') return 'end';
return key;
}
function isWordCharacter(ch: string): boolean {
return /[A-Za-z0-9_]/.test(ch);
}
export function moveCursorWordLeft(text: string, cursorPos: number): number {
if (cursorPos <= 0) return 0;
let pos = cursorPos - 1;
while (pos > 0 && !isWordCharacter(text[pos])) pos -= 1;
while (pos > 0 && isWordCharacter(text[pos - 1])) pos -= 1;
return pos;
}
export function moveCursorWordRight(text: string, cursorPos: number): number {
let pos = cursorPos;
while (pos < text.length && isWordCharacter(text[pos])) pos += 1;
while (pos < text.length && !isWordCharacter(text[pos])) pos += 1;
return pos;
}
function wordAtCursor(text: string, cursorPos: number): string | null {
if (cursorPos < 0 || cursorPos >= text.length || !isWordCharacter(text[cursorPos])) {
return null;
}
let start = cursorPos;
while (start > 0 && isWordCharacter(text[start - 1])) start -= 1;
let end = cursorPos + 1;
while (end < text.length && isWordCharacter(text[end])) end += 1;
return text.slice(start, end);
}
export function describeCharacter(ch: string): string {
if (ch === ' ') return 'space';
if (ch === '\t') return 'tab';
if (ch === '.') return 'period';
if (ch === ',') return 'comma';
if (ch === ':') return 'colon';
if (ch === ';') return 'semicolon';
if (ch === '!') return 'exclamation mark';
if (ch === '?') return 'question mark';
if (ch === "'") return 'apostrophe';
if (ch === '"') return 'quote';
if (ch === '/') return 'slash';
if (ch === '\\') return 'backslash';
if (ch === '-') return 'dash';
if (ch === '_') return 'underscore';
if (ch === '=') return 'equals';
if (ch === '+') return 'plus';
if (ch === '*') return 'asterisk';
if (ch === '&') return 'ampersand';
if (ch === '@') return 'at sign';
if (ch === '#') return 'hash';
if (ch === '%') return 'percent';
if (ch === '$') return 'dollar sign';
if (ch === '^') return 'caret';
if (ch === '|') return 'pipe';
if (ch === '~') return 'tilde';
if (ch === '`') return 'backtick';
if (ch === '(') return 'left parenthesis';
if (ch === ')') return 'right parenthesis';
if (ch === '[') return 'left bracket';
if (ch === ']') return 'right bracket';
if (ch === '{') return 'left brace';
if (ch === '}') return 'right brace';
if (ch === '<') return 'less than';
if (ch === '>') return 'greater than';
return ch;
}
export function describeCursorCharacter(text: string, cursorPos: number): string | null {
if (cursorPos < 0 || cursorPos > text.length) return null;
if (cursorPos === text.length) return 'space';
return describeCharacter(text[cursorPos]);
}
export function describeCursorWordOrCharacter(text: string, cursorPos: number): string | null {
if (cursorPos === text.length) return 'space';
const word = wordAtCursor(text, cursorPos);
if (word) return word;
return describeCursorCharacter(text, cursorPos);
}
export function describeBackspaceDeletedCharacter(text: string, cursorPos: number): string | null {
if (cursorPos <= 0 || cursorPos > text.length) return null;
return describeCharacter(text[cursorPos - 1]);
}

View File

@@ -7,7 +7,17 @@ import {
type EffectId,
} from './audio/effects';
import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
import { applyTextInput } from './input/textInput';
import {
applyPastedText,
applyTextInput,
describeBackspaceDeletedCharacter,
describeCursorCharacter,
describeCursorWordOrCharacter,
mapTextInputKey,
moveCursorWordLeft,
moveCursorWordRight,
shouldReplaceCurrentText,
} from './input/textInput';
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
import { SignalingClient } from './network/signalingClient';
import { CanvasRenderer } from './render/canvasRenderer';
@@ -512,23 +522,6 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
audio.sfxUiBlip();
}
function shouldReplaceCurrentText(code: string, key: string): boolean {
if (!replaceTextOnNextType) return false;
if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') {
replaceTextOnNextType = false;
return false;
}
if (code === 'Backspace' || code === 'Delete') {
replaceTextOnNextType = false;
return false;
}
if (key.length === 1) {
replaceTextOnNextType = false;
return true;
}
return false;
}
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
if (mode === 'chat') return 500;
@@ -536,39 +529,16 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
return null;
}
function normalizePastedText(raw: string): string {
return raw
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n/g, ' ')
.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '');
}
function pasteIntoActiveTextInput(raw: string): boolean {
const maxLength = textInputMaxLengthForMode(state.mode);
if (maxLength === null) {
return false;
}
const text = normalizePastedText(raw);
if (!text) {
return true;
}
if (replaceTextOnNextType) {
const replacement = text.slice(0, maxLength);
state.nicknameInput = replacement;
state.cursorPos = replacement.length;
replaceTextOnNextType = false;
return true;
}
const available = Math.max(0, maxLength - state.nicknameInput.length);
if (available <= 0) {
return true;
}
const insert = text.slice(0, available);
state.nicknameInput =
state.nicknameInput.slice(0, state.cursorPos) + insert + state.nicknameInput.slice(state.cursorPos);
state.cursorPos += insert.length;
replaceTextOnNextType = false;
const result = applyPastedText(raw, state.nicknameInput, state.cursorPos, maxLength, replaceTextOnNextType);
if (!result.handled) return false;
state.nicknameInput = result.newString;
state.cursorPos = result.newCursorPos;
replaceTextOnNextType = result.replaceTextOnNextType;
return true;
}
@@ -591,58 +561,6 @@ function isTextEditingMode(mode: typeof state.mode): boolean {
return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit';
}
function mapTextInputKey(code: string, key: string): string {
if (code === 'ArrowLeft') return 'arrowleft';
if (code === 'ArrowRight') return 'arrowright';
if (code === 'Backspace') return 'backspace';
if (code === 'Home') return 'home';
if (code === 'End') return 'end';
return key;
}
function isWordCharacter(ch: string): boolean {
return /[A-Za-z0-9_]/.test(ch);
}
function moveCursorWordLeft(text: string, cursorPos: number): number {
if (cursorPos <= 0) return 0;
let pos = cursorPos - 1;
while (pos > 0 && !isWordCharacter(text[pos])) pos -= 1;
while (pos > 0 && isWordCharacter(text[pos - 1])) pos -= 1;
return pos;
}
function moveCursorWordRight(text: string, cursorPos: number): number {
let pos = cursorPos;
while (pos < text.length && isWordCharacter(text[pos])) pos += 1;
while (pos < text.length && !isWordCharacter(text[pos])) pos += 1;
return pos;
}
function wordAtCursor(text: string, cursorPos: number): string | null {
if (cursorPos < 0 || cursorPos >= text.length || !isWordCharacter(text[cursorPos])) {
return null;
}
let start = cursorPos;
while (start > 0 && isWordCharacter(text[start - 1])) start -= 1;
let end = cursorPos + 1;
while (end < text.length && isWordCharacter(text[end])) end += 1;
return text.slice(start, end);
}
function announceCursorWordOrCharacter(text: string, cursorPos: number): void {
if (cursorPos === text.length) {
updateStatus('space');
return;
}
const word = wordAtCursor(text, cursorPos);
if (word) {
updateStatus(word);
return;
}
announceCursorCharacter(text, cursorPos);
}
function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKey = false, allowReplaceOnNextType = false): void {
if (ctrlKey && code === 'KeyA') {
replaceTextOnNextType = true;
@@ -652,12 +570,14 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
}
if (ctrlKey && code === 'ArrowLeft') {
state.cursorPos = moveCursorWordLeft(state.nicknameInput, state.cursorPos);
announceCursorWordOrCharacter(state.nicknameInput, state.cursorPos);
const spoken = describeCursorWordOrCharacter(state.nicknameInput, state.cursorPos);
if (spoken) updateStatus(spoken);
return;
}
if (ctrlKey && code === 'ArrowRight') {
state.cursorPos = moveCursorWordRight(state.nicknameInput, state.cursorPos);
announceCursorWordOrCharacter(state.nicknameInput, state.cursorPos);
const spoken = describeCursorWordOrCharacter(state.nicknameInput, state.cursorPos);
if (spoken) updateStatus(spoken);
return;
}
@@ -665,7 +585,9 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
const beforeCursor = state.cursorPos;
const mappedKey = mapTextInputKey(code, key);
if (allowReplaceOnNextType && shouldReplaceCurrentText(code, key)) {
const replaceDecision = shouldReplaceCurrentText(code, key, replaceTextOnNextType);
replaceTextOnNextType = replaceDecision.replaceTextOnNextType;
if (allowReplaceOnNextType && replaceDecision.shouldReplace) {
state.nicknameInput = key;
state.cursorPos = key.length;
return;
@@ -675,51 +597,15 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
state.nicknameInput = result.newString;
state.cursorPos = result.newCursorPos;
if (code === 'Backspace') {
announceBackspaceDeletedCharacter(beforeText, beforeCursor);
const spoken = describeBackspaceDeletedCharacter(beforeText, beforeCursor);
if (spoken) updateStatus(spoken);
}
if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') {
announceCursorCharacter(state.nicknameInput, state.cursorPos);
const spoken = describeCursorCharacter(state.nicknameInput, state.cursorPos);
if (spoken) updateStatus(spoken);
}
}
function describeCharacter(ch: string): string {
if (ch === ' ') return 'space';
if (ch === '\t') return 'tab';
if (ch === '.') return 'period';
if (ch === ',') return 'comma';
if (ch === ':') return 'colon';
if (ch === ';') return 'semicolon';
if (ch === '!') return 'exclamation mark';
if (ch === '?') return 'question mark';
if (ch === "'") return 'apostrophe';
if (ch === '"') return 'quote';
if (ch === '/') return 'slash';
if (ch === '\\') return 'backslash';
if (ch === '-') return 'dash';
if (ch === '_') return 'underscore';
if (ch === '=') return 'equals';
if (ch === '+') return 'plus';
if (ch === '*') return 'asterisk';
if (ch === '&') return 'ampersand';
if (ch === '@') return 'at sign';
if (ch === '#') return 'hash';
if (ch === '%') return 'percent';
if (ch === '$') return 'dollar sign';
if (ch === '^') return 'caret';
if (ch === '|') return 'pipe';
if (ch === '~') return 'tilde';
if (ch === '`') return 'backtick';
if (ch === '(') return 'left parenthesis';
if (ch === ')') return 'right parenthesis';
if (ch === '[') return 'left bracket';
if (ch === ']') return 'right bracket';
if (ch === '{') return 'left brace';
if (ch === '}') return 'right brace';
if (ch === '<') return 'less than';
if (ch === '>') return 'greater than';
return ch;
}
function getItemPropertyValue(item: WorldItem, key: string): string {
if (key === 'title') return item.title;
if (key === 'type') return item.type;
@@ -741,22 +627,6 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
return String(item.params[key] ?? '');
}
function announceCursorCharacter(text: string, cursorPos: number): void {
if (cursorPos < 0 || cursorPos > text.length) {
return;
}
if (cursorPos === text.length) {
updateStatus('space');
return;
}
updateStatus(describeCharacter(text[cursorPos]));
}
function announceBackspaceDeletedCharacter(text: string, cursorPos: number): void {
if (cursorPos <= 0 || cursorPos > text.length) return;
updateStatus(describeCharacter(text[cursorPos - 1]));
}
function squareWord(distance: number): string {
return distance === 1 ? 'square' : 'squares';
}