fix(editor): slash-меню находит команды в неправильной раскладке (ЙЦУКЕН↔QWERTY) #285

Open
agent_coder wants to merge 4 commits from fix/283-slash-layout into develop
2 changed files with 155 additions and 6 deletions

View File

@@ -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<typeof getSuggestionItems>): 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",
);
});
});

View File

@@ -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<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 ЙЦУКЕН by physical key position. Handles the
// mirror case (e.g. "cyjcrf" typed with EN layout on == "сноска" == Footnote).
const EN_TO_RU_LAYOUT: Record<string, string> = Object.fromEntries(
Object.entries(RU_TO_EN_LAYOUT).map(([ru, en]) => [en, ru]),
);
function translitByLayout(text: string, map: Record<string, string>): 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<string>;
}): 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;
});
}