enhnace(frontend): 文字列比較のためのローマナイズを強化(設定の検索) (#15632)

* enhnace(frontend): 文字列比較のためのローマナイズを強化

* docs

* fix

* fix

* fix

* comment

* wanakanaの初回ロードをコンポーネント内に移動

* comment

* fix

* add tests

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
かっこかり
2025-03-09 14:21:23 +09:00
committed by GitHub
parent bdb74539d4
commit f35eb0f6d9
5 changed files with 279 additions and 11 deletions

View File

@@ -6,16 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" class="rrevdjwu" :class="{ grid }">
<MkInput
v-model="search"
v-if="searchIndex && searchIndex.length > 0"
v-model="searchQuery"
:placeholder="i18n.ts.search"
type="search"
style="margin-bottom: 16px;"
@input.passive="searchOnInput"
@keydown="searchOnKeyDown"
>
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<template v-if="search == ''">
<template v-if="rawSearchQuery == ''">
<div v-for="group in def" class="group">
<div v-if="group.title" class="title">{{ group.title }}</div>
@@ -97,17 +99,22 @@ import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n.js';
import { getScrollContainer } from '@@/js/scroll.js';
import { useRouter } from '@/router/supplier.js';
import { initIntlString, compareStringIncludes } from '@/scripts/intl-string.js';
const props = defineProps<{
def: SuperMenuDef[];
grid?: boolean;
searchIndex: SearchIndexItem[];
searchIndex?: SearchIndexItem[];
}>();
initIntlString();
const router = useRouter();
const rootEl = useTemplateRef('rootEl');
const search = ref('');
const searchQuery = ref('');
const rawSearchQuery = ref('');
const searchSelectedIndex = ref<null | number>(null);
const searchResult = ref<{
id: string;
@@ -118,7 +125,11 @@ const searchResult = ref<{
parentLabels: string[];
}[]>([]);
watch(search, (value) => {
watch(searchQuery, (value) => {
rawSearchQuery.value = value;
});
watch(rawSearchQuery, (value) => {
searchResult.value = [];
searchSelectedIndex.value = null;
@@ -128,14 +139,15 @@ watch(search, (value) => {
const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => {
for (const item of items) {
const matched =
item.label.includes(value.toLowerCase()) ||
item.keywords.some((x) => x.toLowerCase().includes(value.toLowerCase()));
const matched = (
compareStringIncludes(item.label, value) ||
item.keywords.some((x) => compareStringIncludes(x, value))
);
if (matched) {
searchResult.value.push({
id: item.id,
path: item.path ?? parents.find((x) => x.path != null)?.path,
path: item.path ?? parents.find((x) => x.path != null)?.path ?? '/', // never gets `/`
label: item.label,
parentLabels: parents.map((x) => x.label).toReversed(),
icon: item.icon ?? parents.find((x) => x.icon != null)?.icon,
@@ -149,9 +161,16 @@ watch(search, (value) => {
}
};
dive(props.searchIndex);
if (props.searchIndex) {
dive(props.searchIndex);
}
});
function searchOnInput(ev: InputEvent) {
searchSelectedIndex.value = null;
rawSearchQuery.value = (ev.target as HTMLInputElement).value;
}
function searchOnKeyDown(ev: KeyboardEvent) {
if (ev.isComposing) return;

View File

@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { versatileLang } from '@@/js/intl-const.js';
import type { toHiragana as toHiraganaType } from 'wanakana';
let toHiragana: typeof toHiraganaType = (str?: string) => str ?? '';
let isWanakanaLoaded = false;
/**
* ローマ字変換のセットアップ日本語以外の環境で読み込まないのでlazy-loading
*
* ここの比較系関数を使う際は事前に呼び出す必要がある
*/
export async function initIntlString(forceWanakana = false) {
if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return;
const { toHiragana: _toHiragana } = await import('wanakana');
toHiragana = _toHiragana;
isWanakanaLoaded = true;
}
/**
* - 全角英数字を半角に
* - 半角カタカナを全角に
* - 濁点・半濁点がリガチャになっている(例: `か` `゛` )ひらがな・カタカナを結合
* - 異体字を正規化
* - 小文字に揃える
* - 文字列のトリム
*/
export function normalizeString(str: string) {
const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' });
return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim();
}
// https://qiita.com/non-caffeine/items/77360dda05c8ce510084
const hyphens = [
0x002d, // hyphen-minus
0x02d7, // modifier letter minus sign
0x1173, // hangul jongseong eu
0x1680, // ogham space mark
0x1b78, // balinese musical symbol left-hand open pang
0x2010, // hyphen
0x2011, // non-breaking hyphen
0x2012, // figure dash
0x2013, // en dash
0x2014, // em dash
0x2015, // horizontal bar
0x2043, // hyphen bullet
0x207b, // superscript minus
0x2212, // minus sign
0x25ac, // black rectangle
0x2500, // box drawings light horizontal
0x2501, // box drawings heavy horizontal
0x2796, // heavy minus sign
0x30fc, // katakana-hiragana prolonged sound mark
0x3161, // hangul letter eu
0xfe58, // small em dash
0xfe63, // small hyphen-minus
0xff0d, // fullwidth hyphen-minus
0xff70, // halfwidth katakana-hiragana prolonged sound mark
0x10110, // aegean number ten
0x10191, // roman uncia sign
];
const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`);
/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */
export function normalizeHyphens(str: string) {
return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d');
}
/**
* `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一
*
* (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり)
*/
export function normalizeStringWithHiragana(str: string) {
return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false }));
}
/** aとbが同じかどうか */
export function compareStringEquals(a: string, b: string) {
return (
normalizeString(a) === normalizeString(b) ||
normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)
);
}
/** baseにqueryが含まれているかどうか */
export function compareStringIncludes(base: string, query: string) {
return (
normalizeString(base).includes(normalizeString(query)) ||
normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))
);
}