Skip to main content
インベントリに戻る
ふりがな入力フック
🔤

ふりがな入力フック

2024エピック

日本語入力時に自動でふりがな(読み仮名)を検知するReactカスタムフック。ローマ字→ひらがな変換テーブルベース。

使用技術

ReactTypeScript
デモ
性(せい)
名(めい)
ふりがな結果
ふりがな結果
useState: {"lastName":"","firstName":"","lastNameFurigana":"","firstNameFurigana":""}
比較分析
項目vanilla-autokanauseHurigana(自作)
方式外部npmライブラリReactカスタムフック
変換原理valueポーリング+ひらがなフィルタcompositionupdate優先+ローマ字変換fallback
React互換DOM直接操作 (getElementById)イベントハンドラースプレッド
UIライブラリ手動連携が必要Chakra UI, MUI等と互換
モバイル入力対応(valueポーリング方式)対応(compositionupdate)
メンテナンス2019年以降更新なし自分で管理可能
カスタマイズ制限的テーブル自由に変更可能
カタカナモードオプション対応内蔵
コピペ非対応(keydown基盤)非対応(keydown基盤)
バンドルサイズ外部依存追加依存なし
TypeScript制限的(v1.1.6)完全対応
ソースコード
1"use client";
2
3import { useRef, useState, useCallback } from "react";
4import type { KeyboardEvent, CompositionEvent, FormEvent } from "react";
5
6interface HuriganaOption {
7 katakana?: boolean;
8}
9
10function toKatakana(src: string): string {
11 return src.replace(/[-]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 0x60));
12}
13
14const ROMAJI_TABLE: [string, string][] = [
15
16 ["xtsu", "っ"], ["ltsu", "っ"],
17 ["xkya", "ゃ"], ["xkyu", "ゅ"], ["xkyo", "ょ"],
18
19 ["sha", "しゃ"], ["shi", "し"], ["shu", "しゅ"], ["sho", "しょ"],
20 ["sya", "しゃ"], ["syu", "しゅ"], ["syo", "しょ"],
21 ["chi", "ち"], ["cha", "ちゃ"], ["chu", "ちゅ"], ["cho", "ちょ"],
22 ["tya", "ちゃ"], ["tyu", "ちゅ"], ["tyo", "ちょ"],
23 ["tsu", "つ"],
24 ["cya", "ちゃ"], ["cyu", "ちゅ"], ["cyo", "ちょ"],
25 ["thi", "てぃ"], ["dhi", "でぃ"], ["dhu", "でゅ"],
26 ["tsa", "つぁ"], ["tsi", "つぃ"], ["tse", "つぇ"], ["tso", "つぉ"],
27 ["wha", "うぁ"], ["whi", "うぃ"], ["whe", "うぇ"], ["who", "うぉ"],
28 ["dzu", "づ"],
29 ["kya", "きゃ"], ["kyu", "きゅ"], ["kyo", "きょ"],
30 ["nya", "にゃ"], ["nyu", "にゅ"], ["nyo", "にょ"],
31 ["hya", "ひゃ"], ["hyu", "ひゅ"], ["hyo", "ひょ"],
32 ["mya", "みゃ"], ["myu", "みゅ"], ["myo", "みょ"],
33 ["rya", "りゃ"], ["ryu", "りゅ"], ["ryo", "りょ"],
34 ["gya", "ぎゃ"], ["gyu", "ぎゅ"], ["gyo", "ぎょ"],
35 ["bya", "びゃ"], ["byu", "びゅ"], ["byo", "びょ"],
36 ["pya", "ぴゃ"], ["pyu", "ぴゅ"], ["pyo", "ぴょ"],
37 ["jya", "じゃ"], ["jyu", "じゅ"], ["jyo", "じょ"],
38 ["zya", "じゃ"], ["zyu", "じゅ"], ["zyo", "じょ"],
39 ["dya", "ぢゃ"], ["dyu", "ぢゅ"], ["dyo", "ぢょ"],
40 ["xtu", "っ"], ["ltu", "っ"],
41 ["xya", "ゃ"], ["xyu", "ゅ"], ["xyo", "ょ"],
42 ["lya", "ゃ"], ["lyu", "ゅ"], ["lyo", "ょ"],
43 ["xwa", "ゎ"],
44
45 ["ka", "か"], ["ki", "き"], ["ku", "く"], ["ke", "け"], ["ko", "こ"],
46 ["sa", "さ"], ["si", "し"], ["su", "す"], ["se", "せ"], ["so", "そ"],
47 ["ta", "た"], ["ti", "ち"], ["tu", "つ"], ["te", "て"], ["to", "と"],
48 ["na", "な"], ["ni", "に"], ["nu", "ぬ"], ["ne", "ね"], ["no", "の"],
49 ["ha", "は"], ["hi", "ひ"], ["hu", "ふ"], ["fu", "ふ"], ["he", "へ"], ["ho", "ほ"],
50 ["ma", "ま"], ["mi", "み"], ["mu", "む"], ["me", "め"], ["mo", "も"],
51 ["ya", "や"], ["yu", "ゆ"], ["yo", "よ"],
52 ["ra", "ら"], ["ri", "り"], ["ru", "る"], ["re", "れ"], ["ro", "ろ"],
53 ["wa", "わ"], ["wi", "ゐ"], ["we", "ゑ"], ["wo", "を"],
54
55 ["ga", "が"], ["gi", "ぎ"], ["gu", "ぐ"], ["ge", "げ"], ["go", "ご"],
56 ["za", "ざ"], ["zi", "じ"], ["ji", "じ"], ["zu", "ず"], ["ze", "ぜ"], ["zo", "ぞ"],
57 ["ja", "じゃ"], ["ju", "じゅ"], ["jo", "じょ"],
58 ["da", "だ"], ["di", "ぢ"], ["du", "づ"], ["de", "で"], ["do", "ど"],
59 ["ba", "ば"], ["bi", "び"], ["bu", "ぶ"], ["be", "べ"], ["bo", "ぼ"],
60 ["pa", "ぱ"], ["pi", "ぴ"], ["pu", "ぷ"], ["pe", "ぺ"], ["po", "ぽ"],
61
62 ["ye", "いぇ"],
63 ["fa", "ふぁ"], ["fi", "ふぃ"], ["fe", "ふぇ"], ["fo", "ふぉ"],
64 ["va", "ゔぁ"], ["vi", "ゔぃ"], ["vu", "ゔ"], ["ve", "ゔぇ"], ["vo", "ゔぉ"],
65
66 ["xa", "ぁ"], ["xi", "ぃ"], ["xu", "ぅ"], ["xe", "ぇ"], ["xo", "ぉ"],
67 ["la", "ぁ"], ["li", "ぃ"], ["lu", "ぅ"], ["le", "ぇ"], ["lo", "ぉ"],
68 ["nn", "ん"],
69
70 ["a", "あ"], ["i", "い"], ["u", "う"], ["e", "え"], ["o", "お"],
71 ["n", "ん"],
72];
73
74const DOUBLE_CONSONANTS = new Set([
75 "bb", "cc", "dd", "ff", "gg", "hh", "jj", "kk", "ll", "mm",
76 "pp", "rr", "ss", "tt", "vv", "ww", "zz",
77]);
78
79function romajiToHiragana(romaji: string): string {
80 let result = "";
81 let buf = romaji.toLowerCase();
82
83 while (buf.length > 0) {
84
85 if (
86 buf[0] === "n" &&
87 buf.length >= 2 &&
88 buf[1] !== "a" && buf[1] !== "i" && buf[1] !== "u" &&
89 buf[1] !== "e" && buf[1] !== "o" && buf[1] !== "y" && buf[1] !== "n"
90 ) {
91 result += "ん";
92 buf = buf.slice(1);
93 continue;
94 }
95
96 if (buf.length >= 2 && DOUBLE_CONSONANTS.has(buf.slice(0, 2))) {
97 result += "っ";
98 buf = buf.slice(1);
99 continue;
100 }
101
102 let matched = false;
103 for (const [rom, hira] of ROMAJI_TABLE) {
104 if (buf.startsWith(rom)) {
105 result += hira;
106 buf = buf.slice(rom.length);
107 matched = true;
108 break;
109 }
110 }
111
112 if (!matched) {
113 buf = buf.slice(1);
114 }
115 }
116
117 return result;
118}
119
120export function useHurigana(option?: HuriganaOption) {
121
122 const [furigana, setFurigana] = useState("");
123
124 const romajiBufferRef = useRef("");
125 const compositionKanaRef = useRef("");
126 const maxDataLenRef = useRef(0);
127 const hadBackspaceRef = useRef(false);
128 const confirmedRef = useRef<string[]>([]);
129
130 const segmentsRef = useRef<{ input: string; kana: string }[]>([]);
131
132 const prevInputLenRef = useRef(0);
133
134 function clearBuffers() {
135 romajiBufferRef.current = "";
136 compositionKanaRef.current = "";
137 maxDataLenRef.current = 0;
138 hadBackspaceRef.current = false;
139 }
140
141 const onKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
142
143 if (e.metaKey || e.ctrlKey || e.altKey) return;
144
145 if (e.key === "Backspace") {
146
147 romajiBufferRef.current = romajiBufferRef.current.slice(0, -1);
148 } else if (e.key.length === 1 && /[a-z]/i.test(e.key)) {
149
150 romajiBufferRef.current += e.key.toLowerCase();
151 } else if (e.code && /^Key[A-Z]$/.test(e.code)) {
152
153 romajiBufferRef.current += e.code.charAt(3).toLowerCase();
154 }
155 }, []);
156
157 const onCompositionUpdate = useCallback((e: CompositionEvent<HTMLInputElement>) => {
158
159 const kana = e.data.replace(/[^-]/g, "");
160
161 const hasKanji = /[-]/.test(e.data);
162
163 maxDataLenRef.current = Math.max(maxDataLenRef.current, e.data.length);
164
165 if (!hasKanji && e.data.length < maxDataLenRef.current) {
166
167 compositionKanaRef.current = kana;
168 maxDataLenRef.current = e.data.length;
169 hadBackspaceRef.current = true;
170 } else if (kana && kana.length >= compositionKanaRef.current.length) {
171
172 compositionKanaRef.current = kana;
173 }
174
175 }, []);
176
177 const onCompositionEnd = useCallback((e: CompositionEvent<HTMLInputElement>) => {
178
179 let kana = compositionKanaRef.current;
180
181 if (romajiBufferRef.current && !hadBackspaceRef.current) {
182 const romajiKana = romajiToHiragana(romajiBufferRef.current);
183
184 if (romajiKana.length > kana.length) {
185 kana = romajiKana;
186 }
187 }
188
189 if (kana) {
190
191 const confirmedInput = e.data || "";
192
193 const actualLen = e.currentTarget.value.length;
194
195 const expectedLen = segmentsRef.current.reduce((sum, s) => sum + s.input.length, 0) + confirmedInput.length;
196
197 if (actualLen !== expectedLen) {
198 segmentsRef.current = [];
199 confirmedRef.current = [];
200 }
201
202 segmentsRef.current.push({ input: confirmedInput, kana });
203 confirmedRef.current.push(kana);
204
205 prevInputLenRef.current = actualLen;
206
207 const joined = confirmedRef.current.join("");
208 setFurigana(option?.katakana ? toKatakana(joined) : joined);
209 }
210
211 clearBuffers();
212 }, [option?.katakana]);
213
214 const onInput = useCallback((e: FormEvent<HTMLInputElement>) => {
215 const nativeEvent = e.nativeEvent as InputEvent;
216
217 if (nativeEvent.isComposing) return;
218
219 romajiBufferRef.current = "";
220
221 const currentValue = e.currentTarget.value;
222
223 if (currentValue === "") {
224 confirmedRef.current = [];
225 segmentsRef.current = [];
226 prevInputLenRef.current = 0;
227 clearBuffers();
228 setFurigana("");
229 return;
230 }
231
232 if (currentValue.length < prevInputLenRef.current) {
233 const deletedCount = prevInputLenRef.current - currentValue.length;
234 let remaining = deletedCount;
235
236 while (remaining > 0 && segmentsRef.current.length > 0) {
237 const lastSeg = segmentsRef.current[segmentsRef.current.length - 1];
238
239 if (remaining >= lastSeg.input.length) {
240
241 remaining -= lastSeg.input.length;
242 segmentsRef.current.pop();
243 confirmedRef.current.pop();
244 } else {
245
246 if (lastSeg.input === lastSeg.kana) {
247
248 const prevTotal = segmentsRef.current.slice(0, -1)
249 .reduce((sum, s) => sum + s.input.length, 0);
250 const actualText = currentValue.slice(prevTotal);
251 lastSeg.input = actualText;
252 lastSeg.kana = actualText;
253 confirmedRef.current[confirmedRef.current.length - 1] = actualText;
254 }
255
256 remaining = 0;
257 }
258 }
259
260 prevInputLenRef.current = currentValue.length;
261 const joined = confirmedRef.current.join("");
262 setFurigana(option?.katakana ? toKatakana(joined) : joined);
263 } else {
264
265 prevInputLenRef.current = currentValue.length;
266 }
267 }, [option?.katakana]);
268
269 const resetFurigana = useCallback(() => {
270 confirmedRef.current = [];
271 segmentsRef.current = [];
272 prevInputLenRef.current = 0;
273 clearBuffers();
274 setFurigana("");
275 }, []);
276
277 const handlers = { onKeyDown, onCompositionUpdate, onCompositionEnd, onInput };
278
279 return { handlers, furigana, resetFurigana };
280}