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:
parent
bdb74539d4
commit
f35eb0f6d9
|
@ -75,7 +75,8 @@
|
|||
"v-code-diff": "1.13.1",
|
||||
"vite": "6.2.1",
|
||||
"vue": "3.5.13",
|
||||
"vuedraggable": "next"
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.0",
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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))
|
||||
);
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { assert, beforeEach, describe, test } from 'vitest';
|
||||
import {
|
||||
normalizeString,
|
||||
initIntlString,
|
||||
normalizeStringWithHiragana,
|
||||
compareStringEquals,
|
||||
compareStringIncludes,
|
||||
} from '@/scripts/intl-string.js';
|
||||
|
||||
// 共通のテストを実行するヘルパー関数
|
||||
const runCommonTests = (normalizeFn: (str: string) => string) => {
|
||||
test('全角英数字が半角の小文字になる', () => {
|
||||
// ローマ字にならないようにする
|
||||
const input = 'B123';
|
||||
const expected = 'b123';
|
||||
assert.strictEqual(normalizeFn(input), expected);
|
||||
});
|
||||
test('濁点・半濁点が正しく結合される', () => {
|
||||
const input = 'か\u3099';
|
||||
const expected = 'が';
|
||||
assert.strictEqual(normalizeFn(input), expected);
|
||||
});
|
||||
test('小文字に揃う', () => {
|
||||
// ローマ字にならないようにする
|
||||
const input = 'tSt';
|
||||
const expected = 'tst';
|
||||
assert.strictEqual(normalizeFn(input), expected);
|
||||
});
|
||||
test('文字列の前後の空白が削除される', () => {
|
||||
const input = ' tst ';
|
||||
const expected = 'tst';
|
||||
assert.strictEqual(normalizeFn(input), expected);
|
||||
});
|
||||
};
|
||||
|
||||
describe('normalize string', () => {
|
||||
runCommonTests(normalizeString);
|
||||
|
||||
test('異体字の正規化 (ligature)', () => {
|
||||
const input = 'fi';
|
||||
const expected = 'fi';
|
||||
assert.strictEqual(normalizeString(input), expected);
|
||||
});
|
||||
|
||||
test('半角カタカナは全角に変換される', () => {
|
||||
const input = 'カタカナ';
|
||||
const expected = 'カタカナ';
|
||||
assert.strictEqual(normalizeString(input), expected);
|
||||
});
|
||||
});
|
||||
|
||||
// normalizeStringWithHiraganaのテスト
|
||||
describe('normalize string with hiragana', () => {
|
||||
beforeEach(async () => {
|
||||
await initIntlString(true);
|
||||
});
|
||||
|
||||
// 共通テスト
|
||||
describe('共通のnormalizeStringテスト', () => {
|
||||
runCommonTests(normalizeStringWithHiragana);
|
||||
});
|
||||
|
||||
test('半角カタカナがひらがなに変換される', () => {
|
||||
const input = 'カタカナ';
|
||||
const expected = 'かたかな';
|
||||
assert.strictEqual(normalizeStringWithHiragana(input), expected);
|
||||
});
|
||||
|
||||
// normalizeStringWithHiragana特有のテスト
|
||||
test('カタカナがひらがなに変換される・伸ばし棒はハイフンに変換される', () => {
|
||||
const input = 'カタカナひーらがーな';
|
||||
const expected = 'かたかなひ-らが-な';
|
||||
assert.strictEqual(normalizeStringWithHiragana(input), expected);
|
||||
});
|
||||
|
||||
test('ローマ字がひらがなに変換される', () => {
|
||||
const input = 'ro-majimohiragananinarimasu';
|
||||
const expected = 'ろ-まじもひらがなになります';
|
||||
assert.strictEqual(normalizeStringWithHiragana(input), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStringEquals', () => {
|
||||
beforeEach(async () => {
|
||||
await initIntlString(true);
|
||||
});
|
||||
|
||||
test('完全一致ならtrue', () => {
|
||||
assert.isTrue(compareStringEquals('テスト', 'テスト'));
|
||||
});
|
||||
|
||||
test('大文字・小文字の違いを無視', () => {
|
||||
assert.isTrue(compareStringEquals('TeSt', 'test'));
|
||||
});
|
||||
|
||||
test('全角・半角の違いを無視', () => {
|
||||
assert.isTrue(compareStringEquals('ABC', 'abc'));
|
||||
});
|
||||
|
||||
test('カタカナとひらがなの違いを無視', () => {
|
||||
assert.isTrue(compareStringEquals('カタカナ', 'かたかな'));
|
||||
});
|
||||
|
||||
test('ローマ字をひらがなと比較可能', () => {
|
||||
assert.isTrue(compareStringEquals('hiragana', 'ひらがな'));
|
||||
});
|
||||
|
||||
test('異なる文字列はfalse', () => {
|
||||
assert.isFalse(compareStringEquals('テスト', 'サンプル'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStringIncludes', () => {
|
||||
test('部分一致ならtrue', () => {
|
||||
assert.isTrue(compareStringIncludes('これはテストです', 'テスト'));
|
||||
});
|
||||
|
||||
test('大文字・小文字の違いを無視', () => {
|
||||
assert.isTrue(compareStringIncludes('This is a Test', 'test'));
|
||||
});
|
||||
|
||||
test('全角・半角の違いを無視', () => {
|
||||
assert.isTrue(compareStringIncludes('ABCDE', 'abc'));
|
||||
});
|
||||
|
||||
test('カタカナとひらがなの違いを無視', () => {
|
||||
assert.isTrue(compareStringIncludes('カタカナのテスト', 'かたかな'));
|
||||
});
|
||||
|
||||
test('ローマ字をひらがなと比較可能', () => {
|
||||
assert.isTrue(compareStringIncludes('これはhiraganaのテスト', 'ひらがな'));
|
||||
});
|
||||
|
||||
test('異なる文字列はfalse', () => {
|
||||
assert.isFalse(compareStringIncludes('これはテストです', 'サンプル'));
|
||||
});
|
||||
});
|
|
@ -874,6 +874,9 @@ importers:
|
|||
vuedraggable:
|
||||
specifier: next
|
||||
version: 4.1.0(vue@3.5.13(typescript@5.8.2))
|
||||
wanakana:
|
||||
specifier: 5.3.1
|
||||
version: 5.3.1
|
||||
devDependencies:
|
||||
'@misskey-dev/summaly':
|
||||
specifier: 5.2.0
|
||||
|
@ -10563,6 +10566,10 @@ packages:
|
|||
walker@1.0.8:
|
||||
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
|
||||
|
||||
wanakana@5.3.1:
|
||||
resolution: {integrity: sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
|
@ -22026,6 +22033,8 @@ snapshots:
|
|||
dependencies:
|
||||
makeerror: 1.0.12
|
||||
|
||||
wanakana@5.3.1: {}
|
||||
|
||||
web-push@3.6.7:
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
|
|
Loading…
Reference in New Issue