[bug][editor] Slash-меню не находит команды при вводе в неправильной раскладке (напр. «/сщву» → «code») #283

Open
opened 2026-07-02 03:44:32 +03:00 by vvzvlad · 0 comments
Owner

Проблема

При наборе slash-команды в неправильной раскладке клавиатуры меню ничего не находит. Пример: физически набираешь code, но включена русская ЙЦУКЕН → в документ попадает /сщву, и поиск возвращает пустой список (а сам попап схлопывается). Поиск ищет буквально сщву, которого нет ни в одном пункте — это логично, но неудобно: пользователь часто забывает переключить раскладку.

Хочется, чтобы поиск находил команду, даже если она набрана в неправильной раскладке (/сщвуCode).

Причина

Фильтрация пунктов меню — в getSuggestionItems в apps/client/src/features/editor/components/slash-menu/menu-items.ts. Запрос приводится к нижнему регистру и матчится тремя способами:

const search = query.toLowerCase();
return (
  fuzzyMatch(search, item.title) ||                        // нечёткий матч по заголовку
  item.description.toLowerCase().includes(search) ||       // подстрока в описании
  (item.searchTerms &&
    item.searchTerms.some((term) => term.includes(search))) // подстрока в searchTerms
);

Все цели (title / description / searchTerms) — латиница (кроме единичных русских терминов вроде сноска/примечание у Footnote). Кириллический сщву не совпадает ни с чем.

Та же функция вызывается в slash-command.ts в колбэке allow() для решения «оставлять ли попап живым». Значит одна правка getSuggestionItems чинит и фильтрацию, и исчезновение попапаslash-command.ts трогать не нужно.

Предлагаемое решение

Добавить нормализацию раскладки: сопоставить кириллицу ЙЦУКЕН латинице QWERTY по физическому положению клавиш и матчить пункт, если совпал хотя бы один из вариантов запроса — оригинал или перекодированные версии. Делаем двунаправленно (RU→EN и EN→RU): дёшево и заодно чинит зеркальный кейс (cyjcrf в EN-раскладке → сноска → находит Footnote). Оригинал всегда среди кандидатов, поэтому легитимные кириллические searchTerms продолжают работать.

// Russian ЙЦУКЕН -> US QWERTY mapping by physical key position. Lets a query
// typed in the wrong keyboard layout (e.g. "/сщву") still match Latin item
// titles/terms (e.g. "code"). Lowercase only; callers lowercase the query first.
const RU_TO_EN_LAYOUT: Record<string, string> = {
  й: "q", ц: "w", у: "e", к: "r", е: "t", н: "y", г: "u", ш: "i",
  щ: "o", з: "p", х: "[", ъ: "]",
  ф: "a", ы: "s", в: "d", а: "f", п: "g", р: "h", о: "j", л: "k",
  д: "l", ж: ";", э: "'",
  я: "z", ч: "x", с: "c", м: "v", и: "b", т: "n", ь: "m", б: ",",
  ю: ".", ё: "`",
};

// Inverse map (US QWERTY -> Russian ЙЦУКЕН), derived once from the table above.
const EN_TO_RU_LAYOUT: Record<string, string> = Object.fromEntries(
  Object.entries(RU_TO_EN_LAYOUT).map(([ru, en]) => [en, ru]),
);

// Remap each character by physical key position; keep unmapped chars as-is.
function translitByLayout(text: string, map: Record<string, string>): string {
  let out = "";
  for (const ch of text) out += map[ch] ?? ch;
  return out;
}

// Original query plus its wrong-layout interpretations (both directions),
// de-duplicated. Matching against ANY candidate makes layout mistakes
// transparent while keeping genuine Cyrillic search terms working.
export function buildLayoutCandidates(search: string): string[] {
  return [
    ...new Set([
      search,
      translitByLayout(search, RU_TO_EN_LAYOUT),
      translitByLayout(search, EN_TO_RU_LAYOUT),
    ]),
  ];
}

Интеграция в getSuggestionItems — матч и tie-break сортировки перебирают кандидатов:

const search = query.toLowerCase();
const candidates = buildLayoutCandidates(search); // [оригинал, RU→EN, EN→RU], уник.

// ...в фильтре:
const title = item.title.toLowerCase();
const description = item.description.toLowerCase();
return candidates.some(
  (c) =>
    fuzzyMatch(c, title) ||
    description.includes(c) ||
    (item.searchTerms?.some((term) => term.includes(c)) ?? false),
);

// ...в сортировке (чтобы попадания по заголовку всплывали и для перекодированного запроса):
const aTitle = candidates.some((c) => a.title.toLowerCase().includes(c)) ? 0 : 1;
const bTitle = candidates.some((c) => b.title.toLowerCase().includes(c)) ? 0 : 1;
return aTitle - bTitle;

Проверка кейса: сщвус→c, щ→o, в→d, у→e = code → матчит Code.

Граничные случаи (учтены, регрессий нет)

  • Пустой запрос → показываются все пункты, как сейчас (fuzzyMatch("", …) и "".includes("") = true).
  • Чисто латинский запрос (code) → RU→EN не меняет строку; EN→RU даёт сщву, который не матчит латиницу, но оригинал матчит. Безвредно.
  • Легитимная кириллица (сноска) → оригинал в кандидатах, матч сохраняется.
  • Пробелы/цифры/пунктуация → не в таблице, остаются как есть (allowSpaces не ломается).
  • Производительность → запрос короткий, кандидатов ≤3, дедуп через Set.
  • Ложные срабатывания от EN→RU маловероятны; при желании можно оставить только RU→EN.

Тесты

Новый файл menu-items.layout.test.ts (по образцу menu-items.suggestions.test.ts, с тем же стабом currentUser в beforeEach):

  • query: "сщву" → результат содержит "Code";
  • query: "уфиду" (физически table) → содержит "Table";
  • query: "cyjcrf" (физически сноска) → содержит "Footnote" (проверка EN→RU);
  • query: "code" (прав��льная раскладка) → по-прежнему содержит "Code" (нет регрессии);
  • юнит buildLayoutCandidates("сщву") → включает "code".

Область изменений

Одна точка — getSuggestionItems в menu-items.ts. Правка чинит и поиск, и удержание попапа; slash-command.ts не трогаем.

## Проблема При наборе slash-команды в неправильной раскладке клавиатуры меню ничего не находит. Пример: физически набираешь `code`, но включена русская ЙЦУКЕН → в документ попадает `/сщву`, и поиск возвращает пустой список (а сам попап схлопывается). Поиск ищет буквально `сщву`, которого нет ни в одном пункте — это логично, но неудобно: пользователь часто забывает переключить раскладку. Хочется, чтобы поиск находил команду, даже если она набрана в неправильной раскладке (`/сщву` → **Code**). ## Причина Фильтрация пунктов меню — в `getSuggestionItems` в [`apps/client/src/features/editor/components/slash-menu/menu-items.ts`](apps/client/src/features/editor/components/slash-menu/menu-items.ts#L838-L883). Запрос приводится к нижнему регистру и матчится тремя способами: ```ts const search = query.toLowerCase(); return ( fuzzyMatch(search, item.title) || // нечёткий матч по заголовку item.description.toLowerCase().includes(search) || // подстрока в описании (item.searchTerms && item.searchTerms.some((term) => term.includes(search))) // подстрока в searchTerms ); ``` Все цели (`title` / `description` / `searchTerms`) — латиница (кроме единичных русских терминов вроде `сноска`/`примечание` у Footnote). Кириллический `сщву` не совпадает ни с чем. Та же функция вызывается в [`slash-command.ts`](apps/client/src/features/editor/extensions/slash-command.ts#L40-L45) в колбэке `allow()` для решения «оставлять ли попап живым». Значит **одна правка `getSuggestionItems` чинит и фильтрацию, и исчезновение попапа** — `slash-command.ts` трогать не нужно. ## Предлагаемое решение Добавить нормализацию раскладки: сопоставить кириллицу ЙЦУКЕН латинице QWERTY **по физическому положению клавиш** и матчить пункт, если совпал хотя бы один из вариантов запроса — оригинал или перекодированные версии. Делаем двунаправленно (RU→EN и EN→RU): дёшево и заодно чинит зеркальный кейс (`cyjcrf` в EN-раскладке → `сноска` → находит Footnote). Оригинал всегда среди кандидатов, поэтому легитимные кириллические `searchTerms` продолжают работать. ```ts // Russian ЙЦУКЕН -> US QWERTY mapping by physical key position. Lets a query // typed in the wrong keyboard layout (e.g. "/сщву") still match Latin item // titles/terms (e.g. "code"). Lowercase only; callers lowercase the query first. const RU_TO_EN_LAYOUT: Record<string, string> = { й: "q", ц: "w", у: "e", к: "r", е: "t", н: "y", г: "u", ш: "i", щ: "o", з: "p", х: "[", ъ: "]", ф: "a", ы: "s", в: "d", а: "f", п: "g", р: "h", о: "j", л: "k", д: "l", ж: ";", э: "'", я: "z", ч: "x", с: "c", м: "v", и: "b", т: "n", ь: "m", б: ",", ю: ".", ё: "`", }; // Inverse map (US QWERTY -> Russian ЙЦУКЕН), derived once from the table above. const EN_TO_RU_LAYOUT: Record<string, string> = Object.fromEntries( Object.entries(RU_TO_EN_LAYOUT).map(([ru, en]) => [en, ru]), ); // Remap each character by physical key position; keep unmapped chars as-is. function translitByLayout(text: string, map: Record<string, string>): string { let out = ""; for (const ch of text) out += map[ch] ?? ch; return out; } // Original query plus its wrong-layout interpretations (both directions), // de-duplicated. Matching against ANY candidate makes layout mistakes // transparent while keeping genuine Cyrillic search terms working. export function buildLayoutCandidates(search: string): string[] { return [ ...new Set([ search, translitByLayout(search, RU_TO_EN_LAYOUT), translitByLayout(search, EN_TO_RU_LAYOUT), ]), ]; } ``` Интеграция в `getSuggestionItems` — матч и tie-break сортировки перебирают кандидатов: ```ts const search = query.toLowerCase(); const candidates = buildLayoutCandidates(search); // [оригинал, RU→EN, EN→RU], уник. // ...в фильтре: const title = item.title.toLowerCase(); const description = item.description.toLowerCase(); return candidates.some( (c) => fuzzyMatch(c, title) || description.includes(c) || (item.searchTerms?.some((term) => term.includes(c)) ?? false), ); // ...в сортировке (чтобы попадания по заголовку всплывали и для перекодированного запроса): const aTitle = candidates.some((c) => a.title.toLowerCase().includes(c)) ? 0 : 1; const bTitle = candidates.some((c) => b.title.toLowerCase().includes(c)) ? 0 : 1; return aTitle - bTitle; ``` Проверка кейса: `сщву` → `с→c, щ→o, в→d, у→e` = `code` → матчит **Code**. ## Граничные случаи (учтены, регрессий нет) - **Пустой запрос** → показываются все пункты, как сейчас (`fuzzyMatch("", …)` и `"".includes("")` = `true`). - **Чисто латинский запрос** (`code`) → RU→EN не меняет строку; EN→RU даёт `сщву`, который не матчит латиницу, но оригинал матчит. Безвредно. - **Легитимная кириллица** (`сноска`) → оригинал в кандидатах, матч сохраняется. - **Пробелы/цифры/пунктуация** → не в таблице, остаются как есть (`allowSpaces` не ломается). - **Производительность** → запрос короткий, кандидатов ≤3, дедуп через `Set`. - Ложные срабатывания от EN→RU маловероятны; при желании можно оставить только RU→EN. ## Тесты Новый файл `menu-items.layout.test.ts` (по образцу `menu-items.suggestions.test.ts`, с тем же стабом `currentUser` в `beforeEach`): - `query: "сщву"` → результат содержит `"Code"`; - `query: "уфиду"` (физически `table`) → содержит `"Table"`; - `query: "cyjcrf"` (физически `сноска`) → содержит `"Footnote"` (проверка EN→RU); - `query: "code"` (прав��льная раскладка) → по-прежнему содержит `"Code"` (нет регрессии); - юнит `buildLayoutCandidates("сщву")` → включает `"code"`. ## Область изменений Одна точка — `getSuggestionItems` в [`menu-items.ts`](apps/client/src/features/editor/components/slash-menu/menu-items.ts). Правка чинит и поиск, и удержание попапа; `slash-command.ts` не трогаем.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#283