+ Automatically replace characters as you type in the message input. Define
+ replacements as a JSON object mapping source strings to their replacements.
+
+
+
+ {textReplaceEnabled && (
+
+
+ )}
+
diff --git a/frontend/src/test/textReplace.test.ts b/frontend/src/test/textReplace.test.ts
new file mode 100644
index 0000000..daaffbf
--- /dev/null
+++ b/frontend/src/test/textReplace.test.ts
@@ -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 });
+ });
+});
diff --git a/frontend/src/utils/textReplace.ts b/frontend/src/utils/textReplace.ts
new file mode 100644
index 0000000..59e3ad8
--- /dev/null
+++ b/frontend/src/utils/textReplace.ts
@@ -0,0 +1,142 @@
+const ENABLED_KEY = 'remoteterm-text-replace-enabled';
+const MAP_KEY = 'remoteterm-text-replace-map';
+
+const DEFAULT_MAP: Record = {
+ А: '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;
+ 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 };
+}