diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.layout.test.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.layout.test.ts new file mode 100644 index 00000000..2e45ed30 --- /dev/null +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.layout.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { + buildLayoutCandidates, + getSuggestionItems, +} from "./menu-items"; + +/** + * `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", + ); + }); + + it("does not surface Footnote for a short wrong-layout query (/cy)", () => { + // "cy" EN->RU remaps to "сн", a substring of the "сноска" searchTerm, but + // the gate blocks it because the remapped candidate is < 3 chars. + expect(titles(getSuggestionItems({ query: "cy" }))).not.toContain( + "Footnote", + ); + }); + + it("does not surface Footnote for a single-char wrong-layout query (/b)", () => { + // "b" EN->RU remaps to "и", a substring of the "примечание" searchTerm, but + // the gate blocks it because the remapped candidate is < 3 chars. + expect(titles(getSuggestionItems({ query: "b" }))).not.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..f9aaa63f 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 @@ -35,6 +35,7 @@ import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed import { CommandProps, SlashMenuGroupedItemsType, + SlashMenuItemType, } from "@/features/editor/components/slash-menu/types"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; @@ -835,6 +836,49 @@ 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 list of search strings to try for a given query: the original + * query first, followed by its RU->EN and EN->RU physical-layout remappings. + * Keeping the original first preserves genuine Cyrillic search terms (e.g. + * "сноска"/"примечание" for Footnote) and lets callers treat the original + * differently from the remapped candidates. De-duplication only collapses the + * list to one element when nothing is remappable (e.g. digits/spaces), so a + * typical ASCII query still yields multiple candidates. + */ +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 +887,18 @@ export const getSuggestionItems = ({ excludeItems?: Set; }): SlashMenuGroupedItemsType => { const search = query.toLowerCase(); + const candidates = buildLayoutCandidates(search); + // Only the original query is allowed to match via a short substring. Remapped + // (wrong-layout) candidates must be at least REMAP_MIN_LEN chars before they + // can match, so a 1-2 char ASCII query does not spuriously substring-match + // unrelated Cyrillic search terms (e.g. "/cy" -> "сн" hitting "сноска", + // "/b" -> "и" hitting "примечание"). buildLayoutCandidates already dedupes + // the remaps against the original, so candidates[0] is the original query. + const REMAP_MIN_LEN = 3; + const [originalCandidate, ...remapped] = candidates; + const remappedCandidates = remapped.filter( + (candidate) => candidate.length >= REMAP_MIN_LEN, + ); const filteredGroups: SlashMenuGroupedItemsType = {}; const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled(); @@ -856,24 +912,42 @@ export const getSuggestionItems = ({ return false; }; + const candidateMatchesItem = ( + candidate: string, + item: SlashMenuItemType, + description: string, + ) => + fuzzyMatch(candidate, item.title) || + description.includes(candidate) || + (item.searchTerms != null && + item.searchTerms.some((term: string) => term.includes(candidate))); + for (const [group, items] of Object.entries(CommandGroups)) { const filteredItems = items.filter((item) => { if (excludeItems?.has(item.title)) return false; // Hide the HTML embed item unless the workspace master toggle is ON. if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled) return false; + const description = item.description.toLowerCase(); return ( - fuzzyMatch(search, item.title) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && - item.searchTerms.some((term: string) => term.includes(search))) + candidateMatchesItem(originalCandidate, item, description) || + remappedCandidates.some((candidate) => + candidateMatchesItem(candidate, item, description), + ) ); }); if (filteredItems.length) { + const titleMatchesAnyCandidate = (title: string) => { + const lower = title.toLowerCase(); + return ( + lower.includes(originalCandidate) || + remappedCandidates.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; }); }