Extract shared list and numeric input helpers

This commit is contained in:
Jage9
2026-02-22 16:41:19 -05:00
parent 481a7550cf
commit 2bcd165d3b
4 changed files with 60 additions and 69 deletions

View File

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

View File

@@ -0,0 +1,27 @@
export function cycleIndex(currentIndex: number, length: number, direction: 'next' | 'prev'): number {
if (length <= 0) return 0;
if (direction === 'next') {
return (currentIndex + 1) % length;
}
return (currentIndex - 1 + length) % length;
}
export function findNextIndexByInitial<T>(
entries: readonly T[],
currentIndex: number,
key: string,
labelFor: (entry: T) => string,
): number {
if (entries.length === 0 || key.length !== 1 || !/[a-z]/i.test(key)) {
return -1;
}
const target = key.toLowerCase();
for (let step = 1; step <= entries.length; step += 1) {
const candidateIndex = (currentIndex + step) % entries.length;
const label = labelFor(entries[candidateIndex]).trim().toLowerCase();
if (label.startsWith(target)) {
return candidateIndex;
}
}
return -1;
}

View File

@@ -0,0 +1,19 @@
export function snapNumberToStep(value: number, step: number, anchor = 0): number {
if (!(step > 0) || !Number.isFinite(value) || !Number.isFinite(anchor)) {
return value;
}
const normalized = Math.round((value - anchor) / step) * step + anchor;
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(Math.abs(Math.log10(step))) + 1);
return Number(normalized.toFixed(decimals));
}
export function formatSteppedNumber(value: number, step: number): string {
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(Math.abs(Math.log10(step))) + 1);
if (decimals <= 0) {
return String(Math.round(value));
}
return value
.toFixed(decimals)
.replace(/(\.\d*?[1-9])0+$/u, '$1')
.replace(/\.0+$/u, '');
}

View File

@@ -28,6 +28,8 @@ import {
moveCursorWordRight, moveCursorWordRight,
shouldReplaceCurrentText, shouldReplaceCurrentText,
} from './input/textInput'; } from './input/textInput';
import { cycleIndex, findNextIndexByInitial } from './input/listNavigation';
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
import { SignalingClient } from './network/signalingClient'; import { SignalingClient } from './network/signalingClient';
import { CanvasRenderer } from './render/canvasRenderer'; import { CanvasRenderer } from './render/canvasRenderer';
@@ -889,26 +891,6 @@ function validateNumericItemPropertyInput(
return { ok: true, value: parsed }; return { ok: true, value: parsed };
} }
function snapNumberToStep(value: number, step: number, anchor = 0): number {
if (!(step > 0) || !Number.isFinite(value) || !Number.isFinite(anchor)) {
return value;
}
const normalized = Math.round((value - anchor) / step) * step + anchor;
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(Math.abs(Math.log10(step))) + 1);
return Number(normalized.toFixed(decimals));
}
function formatSteppedNumber(value: number, step: number): string {
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(Math.abs(Math.log10(step))) + 1);
if (decimals <= 0) {
return String(Math.round(value));
}
return value
.toFixed(decimals)
.replace(/(\.\d*?[1-9])0+$/u, '$1')
.replace(/\.0+$/u, '');
}
function squareWord(distance: number): string { function squareWord(distance: number): string {
return distance === 1 ? 'square' : 'squares'; return distance === 1 ? 'square' : 'squares';
} }
@@ -1922,10 +1904,7 @@ function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean)
function handleEffectSelectModeInput(code: string, key: string): void { function handleEffectSelectModeInput(code: string, key: string): void {
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.effectSelectIndex = state.effectSelectIndex = cycleIndex(state.effectSelectIndex, EFFECT_SEQUENCE.length, code === 'ArrowDown' ? 'next' : 'prev');
code === 'ArrowDown'
? (state.effectSelectIndex + 1) % EFFECT_SEQUENCE.length
: (state.effectSelectIndex - 1 + EFFECT_SEQUENCE.length) % EFFECT_SEQUENCE.length;
updateStatus(EFFECT_SEQUENCE[state.effectSelectIndex].label); updateStatus(EFFECT_SEQUENCE[state.effectSelectIndex].label);
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
@@ -1967,10 +1946,7 @@ function handleListModeInput(code: string, key: string): void {
} }
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.listIndex = state.listIndex = cycleIndex(state.listIndex, state.sortedPeerIds.length, code === 'ArrowDown' ? 'next' : 'prev');
code === 'ArrowDown'
? (state.listIndex + 1) % state.sortedPeerIds.length
: (state.listIndex - 1 + state.sortedPeerIds.length) % state.sortedPeerIds.length;
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
if (!peer) return; if (!peer) return;
updateStatus( updateStatus(
@@ -2025,10 +2001,7 @@ function handleListItemsModeInput(code: string, key: string): void {
return; return;
} }
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.itemListIndex = state.itemListIndex = cycleIndex(state.itemListIndex, state.sortedItemIds.length, code === 'ArrowDown' ? 'next' : 'prev');
code === 'ArrowDown'
? (state.itemListIndex + 1) % state.sortedItemIds.length
: (state.itemListIndex - 1 + state.sortedItemIds.length) % state.sortedItemIds.length;
const item = state.items.get(state.sortedItemIds[state.itemListIndex]); const item = state.items.get(state.sortedItemIds[state.itemListIndex]);
if (!item) return; if (!item) return;
updateStatus( updateStatus(
@@ -2087,10 +2060,7 @@ function handleAddItemModeInput(code: string, key: string): void {
return; return;
} }
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.addItemTypeIndex = state.addItemTypeIndex = cycleIndex(state.addItemTypeIndex, itemTypeSequence.length, code === 'ArrowDown' ? 'next' : 'prev');
code === 'ArrowDown'
? (state.addItemTypeIndex + 1) % itemTypeSequence.length
: (state.addItemTypeIndex - 1 + itemTypeSequence.length) % itemTypeSequence.length;
updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`); updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`);
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
@@ -2133,10 +2103,7 @@ function handleSelectItemModeInput(code: string, key: string): void {
return; return;
} }
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.selectedItemIndex = state.selectedItemIndex = cycleIndex(state.selectedItemIndex, state.selectedItemIds.length, code === 'ArrowDown' ? 'next' : 'prev');
code === 'ArrowDown'
? (state.selectedItemIndex + 1) % state.selectedItemIds.length
: (state.selectedItemIndex - 1 + state.selectedItemIds.length) % state.selectedItemIds.length;
const current = state.items.get(state.selectedItemIds[state.selectedItemIndex]); const current = state.items.get(state.selectedItemIds[state.selectedItemIndex]);
if (current) { if (current) {
updateStatus(itemLabel(current)); updateStatus(itemLabel(current));
@@ -2222,10 +2189,7 @@ function handleItemPropertiesModeInput(code: string, key: string): void {
return; return;
} }
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.itemPropertyIndex = state.itemPropertyIndex = cycleIndex(state.itemPropertyIndex, state.itemPropertyKeys.length, code === 'ArrowDown' ? 'next' : 'prev');
code === 'ArrowDown'
? (state.itemPropertyIndex + 1) % state.itemPropertyKeys.length
: (state.itemPropertyIndex - 1 + state.itemPropertyKeys.length) % state.itemPropertyKeys.length;
const key = state.itemPropertyKeys[state.itemPropertyIndex]; const key = state.itemPropertyKeys[state.itemPropertyIndex];
const value = getItemPropertyValue(item, key); const value = getItemPropertyValue(item, key);
updateStatus(`${itemPropertyLabel(key)}: ${value}`); updateStatus(`${itemPropertyLabel(key)}: ${value}`);
@@ -2515,10 +2479,11 @@ function handleItemPropertyOptionSelectModeInput(code: string, key: string): voi
} }
if (code === 'ArrowDown' || code === 'ArrowUp') { if (code === 'ArrowDown' || code === 'ArrowUp') {
state.itemPropertyOptionIndex = state.itemPropertyOptionIndex = cycleIndex(
code === 'ArrowDown' state.itemPropertyOptionIndex,
? (state.itemPropertyOptionIndex + 1) % state.itemPropertyOptionValues.length state.itemPropertyOptionValues.length,
: (state.itemPropertyOptionIndex - 1 + state.itemPropertyOptionValues.length) % state.itemPropertyOptionValues.length; code === 'ArrowDown' ? 'next' : 'prev',
);
updateStatus(state.itemPropertyOptionValues[state.itemPropertyOptionIndex]); updateStatus(state.itemPropertyOptionValues[state.itemPropertyOptionIndex]);
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
@@ -2587,26 +2552,6 @@ function isTypingKey(code: string): boolean {
return code.startsWith('Key') || code === 'Space'; return code.startsWith('Key') || code === 'Space';
} }
function findNextIndexByInitial<T>(
entries: readonly T[],
currentIndex: number,
key: string,
labelFor: (entry: T) => string,
): number {
if (entries.length === 0 || key.length !== 1 || !/[a-z]/i.test(key)) {
return -1;
}
const target = key.toLowerCase();
for (let step = 1; step <= entries.length; step += 1) {
const candidateIndex = (currentIndex + step) % entries.length;
const label = labelFor(entries[candidateIndex]).trim().toLowerCase();
if (label.startsWith(target)) {
return candidateIndex;
}
}
return -1;
}
function setupInputHandlers(): void { function setupInputHandlers(): void {
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
const code = event.code; const code = event.code;