diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.test.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.test.ts new file mode 100644 index 00000000..874109be --- /dev/null +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { + buildLayoutCandidates, + getSuggestionItems, +} from "@/features/editor/components/slash-menu/menu-items.ts"; + +/** + * `buildLayoutCandidates` maps a slash query across physical keyboard layouts + * (RU ЙЦУКЕН <-> US QWERTY) so the menu matches Latin item titles/terms even + * when typed with the wrong layout active, while keeping the original query so + * genuine Cyrillic search terms still match. See bug #283. + */ +describe("buildLayoutCandidates", () => { + it("remaps a RU-layout query to its US-QWERTY equivalent (сщву -> code)", () => { + expect(buildLayoutCandidates("сщву")).toContain("code"); + }); + + it("remaps a US-layout query to its RU-ЙЦУКЕН equivalent (cyjcrf -> сноска)", () => { + expect(buildLayoutCandidates("cyjcrf")).toContain("сноска"); + }); + + it("always includes the original query", () => { + expect(buildLayoutCandidates("сщву")).toContain("сщву"); + expect(buildLayoutCandidates("cyjcrf")).toContain("cyjcrf"); + expect(buildLayoutCandidates("сноска")).toContain("сноска"); + }); + + it("leaves a query with no mappable keys as a single-element set", () => { + // Digits are on neither layout map, so both remaps are no-ops and de-dup + // back to one entry. + expect(buildLayoutCandidates("123")).toEqual(["123"]); + }); +}); + +/** Helper: flatten grouped suggestion items to a flat list of titles. */ +const titles = (groups: ReturnType): string[] => + Object.values(groups).flatMap((items) => items.map((i) => i.title)); + +describe("getSuggestionItems layout-aware matching", () => { + it("finds Code when 'code' is typed in RU layout (/сщву)", () => { + expect(titles(getSuggestionItems({ query: "сщву" }))).toContain("Code"); + }); + + it("still finds Code for the plain /code query", () => { + expect(titles(getSuggestionItems({ query: "code" }))).toContain("Code"); + }); + + it("still matches genuine Cyrillic search terms (/сноска -> Footnote)", () => { + expect(titles(getSuggestionItems({ query: "сноска" }))).toContain( + "Footnote", + ); + }); + + it("finds Footnote when 'сноска' is typed in EN layout (/cyjcrf)", () => { + expect(titles(getSuggestionItems({ query: "cyjcrf" }))).toContain( + "Footnote", + ); + }); +}); diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 48ca94d5..10ed024d 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -835,6 +835,47 @@ export function isHtmlEmbedFeatureEnabled(): boolean { } } +// Russian ЙЦУКЕН -> US QWERTY by physical key position (lowercase; callers +// lowercase first). Lets the slash menu match Latin item titles/terms even when +// a command is typed with the wrong keyboard layout active (e.g. "/сщву" while +// ЙЦУКЕН is on physically types the same keys as "/code"). +const RU_TO_EN_LAYOUT: Record = { + й: "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 ЙЦУКЕН by physical key position. Handles the +// mirror case (e.g. "cyjcrf" typed with EN layout on == "сноска" == Footnote). +const EN_TO_RU_LAYOUT: Record = Object.fromEntries( + Object.entries(RU_TO_EN_LAYOUT).map(([ru, en]) => [en, ru]), +); + +function translitByLayout(text: string, map: Record): string { + let out = ""; + for (const ch of text) out += map[ch] ?? ch; + return out; +} + +/** + * Build the set of search strings to try for a given query: the original plus + * its RU->EN and EN->RU physical-layout remappings. Keeping the original among + * the candidates preserves genuine Cyrillic search terms (e.g. "сноска", + * "примечание" for Footnote). De-duplicated so an ascii-only query stays a + * single-element set. + */ +export function buildLayoutCandidates(search: string): string[] { + return [ + ...new Set([ + search, + translitByLayout(search, RU_TO_EN_LAYOUT), + translitByLayout(search, EN_TO_RU_LAYOUT), + ]), + ]; +} + export const getSuggestionItems = ({ query, excludeItems, @@ -843,6 +884,7 @@ export const getSuggestionItems = ({ excludeItems?: Set; }): SlashMenuGroupedItemsType => { const search = query.toLowerCase(); + const candidates = buildLayoutCandidates(search); const filteredGroups: SlashMenuGroupedItemsType = {}; const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled(); @@ -862,18 +904,24 @@ export const getSuggestionItems = ({ // Hide the HTML embed item unless the workspace master toggle is ON. if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled) return false; - return ( - fuzzyMatch(search, item.title) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && - item.searchTerms.some((term: string) => term.includes(search))) + const description = item.description.toLowerCase(); + return candidates.some( + (candidate) => + fuzzyMatch(candidate, item.title) || + description.includes(candidate) || + (item.searchTerms && + item.searchTerms.some((term: string) => term.includes(candidate))), ); }); if (filteredItems.length) { + const titleMatchesAnyCandidate = (title: string) => { + const lower = title.toLowerCase(); + return candidates.some((candidate) => lower.includes(candidate)); + }; filteredGroups[group] = filteredItems.sort((a, b) => { - const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1; - const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1; + const aTitle = titleMatchesAnyCandidate(a.title) ? 0 : 1; + const bTitle = titleMatchesAnyCandidate(b.title) ? 0 : 1; return aTitle - bTitle; }); }