Add dynamic text replacement. Closes #223.

This commit is contained in:
Jack Kingsman
2026-04-25 15:00:36 -07:00
parent b3fe717416
commit 25041e1367
5 changed files with 439 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import {
forwardRef,
useRef,
useMemo,
type ChangeEvent,
type FormEvent,
type KeyboardEvent,
} from 'react';
@@ -12,6 +13,11 @@ import { Input } from './ui/input';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
import {
getTextReplaceEnabled,
getTextReplaceMapJson,
applyTextReplacements,
} from '../utils/textReplace';
// MeshCore message size limits (empirically determined from LoRa packet constraints)
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
@@ -139,6 +145,31 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
[text, sending, disabled, onSend]
);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target;
const raw = input.value;
// Skip replacement during IME / dead-key composition to avoid garbling interim input
if (!e.nativeEvent || (e.nativeEvent as InputEvent).isComposing) {
setText(raw);
return;
}
if (getTextReplaceEnabled()) {
const result = applyTextReplacements(
raw,
input.selectionStart ?? raw.length,
getTextReplaceMapJson()
);
if (result) {
setText(result.text);
// Schedule cursor restore after React flushes the new value
const pos = result.cursor;
requestAnimationFrame(() => input.setSelectionRange(pos, pos));
return;
}
}
setText(raw);
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -173,7 +204,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
data-1p-ignore="true"
data-bwignore="true"
value={text}
onChange={(e) => setText(e.target.value)}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}

View File

@@ -99,7 +99,12 @@ export function TelemetryPane({
{data.recv_errors.toLocaleString()}
{data.packets_received > 0 && (
<Secondary>
({((data.recv_errors / (data.packets_received + data.recv_errors)) * 100).toFixed(2)}%)
(
{(
(data.recv_errors / (data.packets_received + data.recv_errors)) *
100
).toFixed(2)}
%)
</Secondary>
)}
</>

View File

@@ -33,6 +33,13 @@ import {
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
import {
getTextReplaceEnabled,
setTextReplaceEnabled as saveTextReplaceEnabled,
getTextReplaceMapJson,
setTextReplaceMapJson,
DEFAULT_MAP_JSON,
} from '../../utils/textReplace';
import {
BATTERY_DISPLAY_CHANGE_EVENT,
getShowBatteryPercent,
@@ -232,6 +239,9 @@ export function SettingsLocalSection({
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled);
const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson);
const [textReplaceError, setTextReplaceError] = useState<string | null>(null);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -439,6 +449,63 @@ export function SettingsLocalSection({
</p>
</div>
</div>
<div className="rounded-md border border-border/60 p-3 space-y-2">
<div className="flex items-start gap-3">
<Checkbox
id="text-replace"
checked={textReplaceEnabled}
onCheckedChange={(checked) => {
const v = checked === true;
setTextReplaceEnabled(v);
saveTextReplaceEnabled(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="text-replace">Replace as you Type</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Automatically replace characters as you type in the message input. Define
replacements as a JSON object mapping source strings to their replacements.
</p>
</div>
</div>
{textReplaceEnabled && (
<div className="space-y-2 pl-7">
<textarea
value={textReplaceJson}
onChange={(e) => {
const val = e.target.value;
setTextReplaceJson(val);
setTextReplaceError(setTextReplaceMapJson(val));
}}
spellCheck={false}
rows={10}
className={cn(
'w-full rounded-md border bg-background px-3 py-2 text-sm font-mono',
textReplaceError ? 'border-destructive' : 'border-input'
)}
aria-label="Text replacement map (JSON)"
/>
{textReplaceError && (
<p className="text-xs text-destructive">
{textReplaceError} Changes are not saved until this is resolved.
</p>
)}
<button
type="button"
onClick={() => {
setTextReplaceJson(DEFAULT_MAP_JSON);
setTextReplaceMapJson(DEFAULT_MAP_JSON);
setTextReplaceError(null);
}}
className="inline-flex h-8 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Reset to Default
</button>
</div>
)}
</div>
</div>
<div className="space-y-3">

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getTextReplaceEnabled,
setTextReplaceEnabled,
getTextReplaceMapJson,
setTextReplaceMapJson,
applyTextReplacements,
DEFAULT_MAP_JSON,
} from '../utils/textReplace';
beforeEach(() => {
localStorage.clear();
});
describe('enabled toggle', () => {
it('defaults to disabled', () => {
expect(getTextReplaceEnabled()).toBe(false);
});
it('persists enabled state', () => {
setTextReplaceEnabled(true);
expect(getTextReplaceEnabled()).toBe(true);
setTextReplaceEnabled(false);
expect(getTextReplaceEnabled()).toBe(false);
});
});
describe('map JSON persistence', () => {
it('returns default map when nothing stored', () => {
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
});
it('persists valid JSON and returns null', () => {
const json = '{"a":"b"}';
expect(setTextReplaceMapJson(json)).toBeNull();
expect(getTextReplaceMapJson()).toBe(json);
});
it('rejects invalid JSON with error string', () => {
const err = setTextReplaceMapJson('not json');
expect(err).toBeTypeOf('string');
// localStorage unchanged — still returns default
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
});
it('rejects arrays', () => {
expect(setTextReplaceMapJson('["a","b"]')).toBeTypeOf('string');
});
it('rejects non-string values', () => {
expect(setTextReplaceMapJson('{"a":123}')).toBeTypeOf('string');
});
it('rejects null', () => {
expect(setTextReplaceMapJson('null')).toBeTypeOf('string');
});
it('accepts empty object', () => {
expect(setTextReplaceMapJson('{}')).toBeNull();
});
});
describe('re-expansion validation', () => {
it('rejects when a key appears in its own replacement', () => {
const err = setTextReplaceMapJson(JSON.stringify({ a: 'aa' }));
expect(err).toBeTypeOf('string');
expect(err).toContain('"a"');
expect(err).toContain('"aa"');
});
it('rejects when a key appears in another replacement', () => {
const err = setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'ab' }));
expect(err).toBeTypeOf('string');
expect(err).toContain('"a"');
expect(err).toContain('"ab"');
});
it('allows replacements that do not contain any key', () => {
expect(setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'Y' }))).toBeNull();
});
it('allows the default Cyrillic map', () => {
expect(setTextReplaceMapJson(DEFAULT_MAP_JSON)).toBeNull();
});
it('does not check empty keys for re-expansion', () => {
// Empty key is silently skipped by buildReplacements, so it should not
// cause a re-expansion rejection for other entries.
expect(setTextReplaceMapJson(JSON.stringify({ '': 'x', b: 'Y' }))).toBeNull();
});
});
describe('applyTextReplacements', () => {
const simpleMap = JSON.stringify({ a: 'X', b: 'Y' });
it('returns null when no replacements match', () => {
expect(applyTextReplacements('hello', 5, simpleMap)).toBeNull();
});
it('returns null for empty map', () => {
expect(applyTextReplacements('abc', 3, '{}')).toBeNull();
});
it('returns null for invalid JSON', () => {
expect(applyTextReplacements('abc', 3, 'broken')).toBeNull();
});
it('replaces a single character with cursor at end', () => {
const result = applyTextReplacements('a', 1, simpleMap);
expect(result).toEqual({ text: 'X', cursor: 1 });
});
it('replaces multiple characters in one pass', () => {
const result = applyTextReplacements('ab', 2, simpleMap);
expect(result).toEqual({ text: 'XY', cursor: 2 });
});
it('adjusts cursor when replacement is longer than needle', () => {
const map = JSON.stringify({ ':)': 'smiley' });
// "hello :)" cursor at end (8)
const result = applyTextReplacements('hello :)', 8, map);
expect(result).toEqual({ text: 'hello smiley', cursor: 12 });
});
it('adjusts cursor when replacement is shorter than needle', () => {
const map = JSON.stringify({ abc: 'Z' });
// "abcdef" cursor at end (6)
const result = applyTextReplacements('abcdef', 6, map);
expect(result).toEqual({ text: 'Zdef', cursor: 4 });
});
it('preserves cursor position when replacement is before cursor', () => {
const map = JSON.stringify({ a: 'XX' });
// "a_b" cursor at 2 (on 'b'), 'a' replaced with 'XX'
const result = applyTextReplacements('a_b', 2, map);
expect(result).toEqual({ text: 'XX_b', cursor: 3 });
});
it('does not adjust cursor for replacements after cursor', () => {
const map = JSON.stringify({ b: 'YY' });
// "ab" cursor at 1 (after 'a'), 'b' is after cursor
const result = applyTextReplacements('ab', 1, map);
expect(result).toEqual({ text: 'aYY', cursor: 1 });
});
it('places cursor after replacement when cursor is inside a multi-char match', () => {
const map = JSON.stringify({ abc: 'Z' });
// "abc" cursor at 2 (inside the match)
const result = applyTextReplacements('abc', 2, map);
expect(result).toEqual({ text: 'Z', cursor: 1 });
});
it('handles multiple replacements with cursor tracking', () => {
const map = JSON.stringify({ ':)': 'S' });
// ":):)" cursor at end (4) — two replacements, each shrinks by 1
const result = applyTextReplacements(':):)', 4, map);
expect(result).toEqual({ text: 'SS', cursor: 2 });
});
it('cursor between two replacements stays correct', () => {
const map = JSON.stringify({ ':)': 'S' });
// ":):)" cursor at 2 (between the two smileys)
const result = applyTextReplacements(':):)', 2, map);
expect(result).toEqual({ text: 'SS', cursor: 1 });
});
it('uses longest match first', () => {
const map = JSON.stringify({ ab: 'LONG', a: 'X' });
const result = applyTextReplacements('ab', 2, map);
expect(result).toEqual({ text: 'LONG', cursor: 4 });
});
it('ignores empty-string keys (no infinite loop)', () => {
const map = JSON.stringify({ '': 'oops', a: 'X' });
const result = applyTextReplacements('abc', 3, map);
expect(result).toEqual({ text: 'Xbc', cursor: 3 });
});
it('works with the default Cyrillic map', () => {
// "Привет" — П has no mapping, р→p, и has no mapping, в has no mapping, е→e, т has no mapping
const result = applyTextReplacements('Привет', 6, DEFAULT_MAP_JSON);
expect(result).not.toBeNull();
expect(result!.text).toBe('Пpивeт');
expect(result!.cursor).toBe(6);
});
it('handles paste with many replacements', () => {
const map = JSON.stringify({ А: 'A', В: 'B', С: 'C' });
const result = applyTextReplacements('АВС', 3, map);
expect(result).toEqual({ text: 'ABC', cursor: 3 });
});
});

View File

@@ -0,0 +1,142 @@
const ENABLED_KEY = 'remoteterm-text-replace-enabled';
const MAP_KEY = 'remoteterm-text-replace-map';
const DEFAULT_MAP: Record<string, string> = {
А: 'A',
В: 'B',
Е: 'E',
Ё: 'E',
З: '3',
К: 'K',
М: 'M',
Н: 'H',
О: 'O',
Р: 'P',
С: 'C',
Т: 'T',
Х: 'X',
Ь: 'b',
а: 'a',
е: 'e',
ё: 'e',
о: 'o',
р: 'p',
с: 'c',
у: 'y',
х: 'x',
};
export const DEFAULT_MAP_JSON = JSON.stringify(DEFAULT_MAP, null, 2);
export function getTextReplaceEnabled(): boolean {
try {
return localStorage.getItem(ENABLED_KEY) === 'true';
} catch {
return false;
}
}
export function setTextReplaceEnabled(enabled: boolean): void {
try {
if (enabled) {
localStorage.setItem(ENABLED_KEY, 'true');
} else {
localStorage.removeItem(ENABLED_KEY);
}
} catch {
// localStorage may be unavailable
}
}
export function getTextReplaceMapJson(): string {
try {
const raw = localStorage.getItem(MAP_KEY);
if (raw !== null) return raw;
} catch {
// fall through
}
return DEFAULT_MAP_JSON;
}
/** Persist the map JSON only if it's valid. Returns null on success or an error string. */
export function setTextReplaceMapJson(json: string): string | null {
try {
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
return 'Must be a JSON object.';
const rawEntries = Object.entries(parsed);
for (const [k, v] of rawEntries) {
if (typeof k !== 'string' || typeof v !== 'string')
return 'All keys and values must be strings.';
}
const entries = rawEntries as [string, string][];
// Check for re-expansion: no key may appear as a substring of any replacement value.
for (const [needle] of entries) {
if (needle.length === 0) continue;
for (const [, replacement] of entries) {
if (replacement.includes(needle)) {
return `Key "${needle}" appears inside replacement "${replacement}" and would re-expand on every keystroke.`;
}
}
}
localStorage.setItem(MAP_KEY, json);
return null;
} catch {
return 'Invalid JSON.';
}
}
/** Build a sorted-by-length-desc array of [needle, replacement] for efficient matching. */
function buildReplacements(json: string): [string, string][] {
try {
const parsed = JSON.parse(json) as Record<string, string>;
return Object.entries(parsed)
.filter(([k]) => k.length > 0)
.sort((a, b) => b[0].length - a[0].length);
} catch {
return [];
}
}
/**
* Apply text replacements and compute the adjusted cursor position.
* Returns null if nothing changed.
*/
export function applyTextReplacements(
text: string,
cursorPos: number,
mapJson: string
): { text: string; cursor: number } | null {
const replacements = buildReplacements(mapJson);
if (replacements.length === 0) return null;
let result = '';
let newCursor = cursorPos;
let i = 0;
while (i < text.length) {
let matched = false;
for (const [needle, replacement] of replacements) {
if (text.startsWith(needle, i)) {
result += replacement;
// Adjust cursor if this match is before or spans the cursor
if (i + needle.length <= cursorPos) {
newCursor += replacement.length - needle.length;
} else if (i < cursorPos) {
// Cursor is inside this match — place it after the replacement
newCursor = result.length;
}
i += needle.length;
matched = true;
break;
}
}
if (!matched) {
result += text[i];
i++;
}
}
if (result === text) return null;
return { text: result, cursor: newCursor };
}