Skip to content

Commit bcab1ce

Browse files
kakkokari-gtyihsyuilo
authored andcommitted
enhnace(frontend): 文字列比較のためのローマナイズを強化(設定の検索) (misskey-dev#15632)
* enhnace(frontend): 文字列比較のためのローマナイズを強化 * docs * fix * fix * fix * comment * wanakanaの初回ロードをコンポーネント内に移動 * comment * fix * add tests --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
1 parent c08a793 commit bcab1ce

File tree

5 files changed

+279
-11
lines changed

5 files changed

+279
-11
lines changed

packages/frontend/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@
7575
"v-code-diff": "1.13.1",
7676
"vite": "6.2.1",
7777
"vue": "3.5.13",
78-
"vuedraggable": "next"
78+
"vuedraggable": "next",
79+
"wanakana": "5.3.1"
7980
},
8081
"devDependencies": {
8182
"@misskey-dev/summaly": "5.2.0",

packages/frontend/src/components/MkSuperMenu.vue

+29-10
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only
66
<template>
77
<div ref="rootEl" class="rrevdjwu" :class="{ grid }">
88
<MkInput
9-
v-model="search"
9+
v-if="searchIndex && searchIndex.length > 0"
10+
v-model="searchQuery"
1011
:placeholder="i18n.ts.search"
1112
type="search"
1213
style="margin-bottom: 16px;"
14+
@input.passive="searchOnInput"
1315
@keydown="searchOnKeyDown"
1416
>
1517
<template #prefix><i class="ti ti-search"></i></template>
1618
</MkInput>
1719

18-
<template v-if="search == ''">
20+
<template v-if="rawSearchQuery == ''">
1921
<div v-for="group in def" class="group">
2022
<div v-if="group.title" class="title">{{ group.title }}</div>
2123

@@ -97,17 +99,22 @@ import MkInput from '@/components/MkInput.vue';
9799
import { i18n } from '@/i18n.js';
98100
import { getScrollContainer } from '@@/js/scroll.js';
99101
import { useRouter } from '@/router/supplier.js';
102+
import { initIntlString, compareStringIncludes } from '@/scripts/intl-string.js';
100103

101104
const props = defineProps<{
102105
def: SuperMenuDef[];
103106
grid?: boolean;
104-
searchIndex: SearchIndexItem[];
107+
searchIndex?: SearchIndexItem[];
105108
}>();
106109

110+
initIntlString();
111+
107112
const router = useRouter();
108113
const rootEl = useTemplateRef('rootEl');
109114

110-
const search = ref('');
115+
const searchQuery = ref('');
116+
const rawSearchQuery = ref('');
117+
111118
const searchSelectedIndex = ref<null | number>(null);
112119
const searchResult = ref<{
113120
id: string;
@@ -118,7 +125,11 @@ const searchResult = ref<{
118125
parentLabels: string[];
119126
}[]>([]);
120127

121-
watch(search, (value) => {
128+
watch(searchQuery, (value) => {
129+
rawSearchQuery.value = value;
130+
});
131+
132+
watch(rawSearchQuery, (value) => {
122133
searchResult.value = [];
123134
searchSelectedIndex.value = null;
124135

@@ -128,14 +139,15 @@ watch(search, (value) => {
128139

129140
const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => {
130141
for (const item of items) {
131-
const matched =
132-
item.label.includes(value.toLowerCase()) ||
133-
item.keywords.some((x) => x.toLowerCase().includes(value.toLowerCase()));
142+
const matched = (
143+
compareStringIncludes(item.label, value) ||
144+
item.keywords.some((x) => compareStringIncludes(x, value))
145+
);
134146

135147
if (matched) {
136148
searchResult.value.push({
137149
id: item.id,
138-
path: item.path ?? parents.find((x) => x.path != null)?.path,
150+
path: item.path ?? parents.find((x) => x.path != null)?.path ?? '/', // never gets `/`
139151
label: item.label,
140152
parentLabels: parents.map((x) => x.label).toReversed(),
141153
icon: item.icon ?? parents.find((x) => x.icon != null)?.icon,
@@ -149,9 +161,16 @@ watch(search, (value) => {
149161
}
150162
};
151163

152-
dive(props.searchIndex);
164+
if (props.searchIndex) {
165+
dive(props.searchIndex);
166+
}
153167
});
154168

169+
function searchOnInput(ev: InputEvent) {
170+
searchSelectedIndex.value = null;
171+
rawSearchQuery.value = (ev.target as HTMLInputElement).value;
172+
}
173+
155174
function searchOnKeyDown(ev: KeyboardEvent) {
156175
if (ev.isComposing) return;
157176

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* SPDX-FileCopyrightText: syuilo and misskey-project
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
import { versatileLang } from '@@/js/intl-const.js';
7+
import type { toHiragana as toHiraganaType } from 'wanakana';
8+
9+
let toHiragana: typeof toHiraganaType = (str?: string) => str ?? '';
10+
let isWanakanaLoaded = false;
11+
12+
/**
13+
* ローマ字変換のセットアップ(日本語以外の環境で読み込まないのでlazy-loading)
14+
*
15+
* ここの比較系関数を使う際は事前に呼び出す必要がある
16+
*/
17+
export async function initIntlString(forceWanakana = false) {
18+
if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return;
19+
const { toHiragana: _toHiragana } = await import('wanakana');
20+
toHiragana = _toHiragana;
21+
isWanakanaLoaded = true;
22+
}
23+
24+
/**
25+
* - 全角英数字を半角に
26+
* - 半角カタカナを全角に
27+
* - 濁点・半濁点がリガチャになっている(例: `か` + `゛` )ひらがな・カタカナを結合
28+
* - 異体字を正規化
29+
* - 小文字に揃える
30+
* - 文字列のトリム
31+
*/
32+
export function normalizeString(str: string) {
33+
const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' });
34+
return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim();
35+
}
36+
37+
// https://qiita.com/non-caffeine/items/77360dda05c8ce510084
38+
const hyphens = [
39+
0x002d, // hyphen-minus
40+
0x02d7, // modifier letter minus sign
41+
0x1173, // hangul jongseong eu
42+
0x1680, // ogham space mark
43+
0x1b78, // balinese musical symbol left-hand open pang
44+
0x2010, // hyphen
45+
0x2011, // non-breaking hyphen
46+
0x2012, // figure dash
47+
0x2013, // en dash
48+
0x2014, // em dash
49+
0x2015, // horizontal bar
50+
0x2043, // hyphen bullet
51+
0x207b, // superscript minus
52+
0x2212, // minus sign
53+
0x25ac, // black rectangle
54+
0x2500, // box drawings light horizontal
55+
0x2501, // box drawings heavy horizontal
56+
0x2796, // heavy minus sign
57+
0x30fc, // katakana-hiragana prolonged sound mark
58+
0x3161, // hangul letter eu
59+
0xfe58, // small em dash
60+
0xfe63, // small hyphen-minus
61+
0xff0d, // fullwidth hyphen-minus
62+
0xff70, // halfwidth katakana-hiragana prolonged sound mark
63+
0x10110, // aegean number ten
64+
0x10191, // roman uncia sign
65+
];
66+
67+
const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`);
68+
69+
/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */
70+
export function normalizeHyphens(str: string) {
71+
return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d');
72+
}
73+
74+
/**
75+
* `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一
76+
*
77+
* (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり)
78+
*/
79+
export function normalizeStringWithHiragana(str: string) {
80+
return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false }));
81+
}
82+
83+
/** aとbが同じかどうか */
84+
export function compareStringEquals(a: string, b: string) {
85+
return (
86+
normalizeString(a) === normalizeString(b) ||
87+
normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)
88+
);
89+
}
90+
91+
/** baseにqueryが含まれているかどうか */
92+
export function compareStringIncludes(base: string, query: string) {
93+
return (
94+
normalizeString(base).includes(normalizeString(query)) ||
95+
normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))
96+
);
97+
}
+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* SPDX-FileCopyrightText: syuilo and misskey-project
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
import { assert, beforeEach, describe, test } from 'vitest';
7+
import {
8+
normalizeString,
9+
initIntlString,
10+
normalizeStringWithHiragana,
11+
compareStringEquals,
12+
compareStringIncludes,
13+
} from '@/scripts/intl-string.js';
14+
15+
// 共通のテストを実行するヘルパー関数
16+
const runCommonTests = (normalizeFn: (str: string) => string) => {
17+
test('全角英数字が半角の小文字になる', () => {
18+
// ローマ字にならないようにする
19+
const input = 'B123';
20+
const expected = 'b123';
21+
assert.strictEqual(normalizeFn(input), expected);
22+
});
23+
test('濁点・半濁点が正しく結合される', () => {
24+
const input = 'か\u3099';
25+
const expected = 'が';
26+
assert.strictEqual(normalizeFn(input), expected);
27+
});
28+
test('小文字に揃う', () => {
29+
// ローマ字にならないようにする
30+
const input = 'tSt';
31+
const expected = 'tst';
32+
assert.strictEqual(normalizeFn(input), expected);
33+
});
34+
test('文字列の前後の空白が削除される', () => {
35+
const input = ' tst ';
36+
const expected = 'tst';
37+
assert.strictEqual(normalizeFn(input), expected);
38+
});
39+
};
40+
41+
describe('normalize string', () => {
42+
runCommonTests(normalizeString);
43+
44+
test('異体字の正規化 (ligature)', () => {
45+
const input = 'fi';
46+
const expected = 'fi';
47+
assert.strictEqual(normalizeString(input), expected);
48+
});
49+
50+
test('半角カタカナは全角に変換される', () => {
51+
const input = 'カタカナ';
52+
const expected = 'カタカナ';
53+
assert.strictEqual(normalizeString(input), expected);
54+
});
55+
});
56+
57+
// normalizeStringWithHiraganaのテスト
58+
describe('normalize string with hiragana', () => {
59+
beforeEach(async () => {
60+
await initIntlString(true);
61+
});
62+
63+
// 共通テスト
64+
describe('共通のnormalizeStringテスト', () => {
65+
runCommonTests(normalizeStringWithHiragana);
66+
});
67+
68+
test('半角カタカナがひらがなに変換される', () => {
69+
const input = 'カタカナ';
70+
const expected = 'かたかな';
71+
assert.strictEqual(normalizeStringWithHiragana(input), expected);
72+
});
73+
74+
// normalizeStringWithHiragana特有のテスト
75+
test('カタカナがひらがなに変換される・伸ばし棒はハイフンに変換される', () => {
76+
const input = 'カタカナひーらがーな';
77+
const expected = 'かたかなひ-らが-な';
78+
assert.strictEqual(normalizeStringWithHiragana(input), expected);
79+
});
80+
81+
test('ローマ字がひらがなに変換される', () => {
82+
const input = 'ro-majimohiragananinarimasu';
83+
const expected = 'ろ-まじもひらがなになります';
84+
assert.strictEqual(normalizeStringWithHiragana(input), expected);
85+
});
86+
});
87+
88+
describe('compareStringEquals', () => {
89+
beforeEach(async () => {
90+
await initIntlString(true);
91+
});
92+
93+
test('完全一致ならtrue', () => {
94+
assert.isTrue(compareStringEquals('テスト', 'テスト'));
95+
});
96+
97+
test('大文字・小文字の違いを無視', () => {
98+
assert.isTrue(compareStringEquals('TeSt', 'test'));
99+
});
100+
101+
test('全角・半角の違いを無視', () => {
102+
assert.isTrue(compareStringEquals('ABC', 'abc'));
103+
});
104+
105+
test('カタカナとひらがなの違いを無視', () => {
106+
assert.isTrue(compareStringEquals('カタカナ', 'かたかな'));
107+
});
108+
109+
test('ローマ字をひらがなと比較可能', () => {
110+
assert.isTrue(compareStringEquals('hiragana', 'ひらがな'));
111+
});
112+
113+
test('異なる文字列はfalse', () => {
114+
assert.isFalse(compareStringEquals('テスト', 'サンプル'));
115+
});
116+
});
117+
118+
describe('compareStringIncludes', () => {
119+
test('部分一致ならtrue', () => {
120+
assert.isTrue(compareStringIncludes('これはテストです', 'テスト'));
121+
});
122+
123+
test('大文字・小文字の違いを無視', () => {
124+
assert.isTrue(compareStringIncludes('This is a Test', 'test'));
125+
});
126+
127+
test('全角・半角の違いを無視', () => {
128+
assert.isTrue(compareStringIncludes('ABCDE', 'abc'));
129+
});
130+
131+
test('カタカナとひらがなの違いを無視', () => {
132+
assert.isTrue(compareStringIncludes('カタカナのテスト', 'かたかな'));
133+
});
134+
135+
test('ローマ字をひらがなと比較可能', () => {
136+
assert.isTrue(compareStringIncludes('これはhiraganaのテスト', 'ひらがな'));
137+
});
138+
139+
test('異なる文字列はfalse', () => {
140+
assert.isFalse(compareStringIncludes('これはテストです', 'サンプル'));
141+
});
142+
});

pnpm-lock.yaml

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)