Compare commits
1 Commits
fix/283-sl
...
feat/273-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703b883165 |
@@ -1,75 +0,0 @@
|
||||
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<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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,6 @@ 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";
|
||||
@@ -836,49 +835,6 @@ 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,
|
||||
@@ -887,18 +843,6 @@ 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 and differ
|
||||
// from the original 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 "примечание").
|
||||
const REMAP_MIN_LEN = 3;
|
||||
const [originalCandidate, ...remapped] = candidates;
|
||||
const remappedCandidates = remapped.filter(
|
||||
(candidate) =>
|
||||
candidate.length >= REMAP_MIN_LEN && candidate !== originalCandidate,
|
||||
);
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||
|
||||
@@ -912,42 +856,24 @@ 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 (
|
||||
candidateMatchesItem(originalCandidate, item, description) ||
|
||||
remappedCandidates.some((candidate) =>
|
||||
candidateMatchesItem(candidate, item, description),
|
||||
)
|
||||
fuzzyMatch(search, item.title) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
});
|
||||
|
||||
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 = titleMatchesAnyCandidate(a.title) ? 0 : 1;
|
||||
const bTitle = titleMatchesAnyCandidate(b.title) ? 0 : 1;
|
||||
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
return aTitle - bTitle;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
@@ -31,6 +33,11 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
// Reuse the exact soft-delete path the tree/header menus use: optimistic
|
||||
// tree removal, the "Page moved to trash" undo-toast, the deletedAt cache
|
||||
// stamp, and the redirect to space home (which unmounts this banner).
|
||||
const { handleDelete: trashPage } = useTreeMutation(page?.spaceId ?? "");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Don't show on a note that is already in trash; the deleted-page banner
|
||||
// owns that state.
|
||||
@@ -38,6 +45,16 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
|
||||
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||
|
||||
const handleTrashNow = async () => {
|
||||
// No confirm modal by convention — the undo-toast is the safety net.
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await trashPage(page.id);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakePermanent = async () => {
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
@@ -70,16 +87,28 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user