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
+32 -1
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}
@@ -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>
)}
</>
@@ -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">