Reactで、ローマ字をひらがなに変換する入力欄を作る方法

​​React​Playwright,TypeScript

ローマ字をひらがなに変える処理は、つづりとひらがなの対応表を作り、先頭からいちばん長く当てはまるつづりを探していけば書けます。表だけで決まらないのは「ん」と「っ」で、次に来る文字を見て決める決まりを足します。

入力欄に組み込むときは、onChange で欄の文字を変換して state に入れます。ただ、欄の文字全体を変換すると、文字の途中に打ったときにカーソルが末尾へ飛びました。ここでは、カーソルより前だけを変換し、変換のあとでカーソルを置き直す RomajiInput という部品を作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。tsc で型エラーがないことを確かめ、コンポーネントを StrictMode で包んで esbuild で開発用にビルドし、Playwright でキーを1つずつ送って操作しました。日本語入力(IME)をオンにしたままの入力、スマートフォンのソフトウェアキーボード、Firefox と Safari は試していません。カタカナへの変換も扱っていません。

白い背景に「よみがな」というラベルの入力欄があり、「にゅうりょくk」と入っている。最後の k だけがローマ字のまま残っている
nyuuryokuk と打ったところ。打ちかけの k は、次の文字が来るまでローマ字のまま残る

つづりの表と、変換する関数

ts
// ローマ字をひらがなに変換する関数

// 5つの母音と、子音ごとの行(母音の順は a i u e o)
const VOWELS = 'aiueo';
const ROWS: Record<string, string> = {
  '': 'あいうえお',
  k: 'かきくけこ',
  s: 'さしすせそ',
  t: 'たちつてと',
  n: 'なにぬねの',
  h: 'はひふへほ',
  m: 'まみむめも',
  r: 'らりるれろ',
  g: 'がぎぐげご',
  z: 'ざじずぜぞ',
  d: 'だぢづでど',
  b: 'ばびぶべぼ',
  p: 'ぱぴぷぺぽ',
  x: 'ぁぃぅぇぉ',
  l: 'ぁぃぅぇぉ',
};

const TABLE: Record<string, string> = {};
for (const [c, kana] of Object.entries(ROWS)) {
  [...VOWELS].forEach((v, i) => (TABLE[c + v] = kana[i]));
}
// 「きゃ」「しゃ」のように、小さい「ゃゅょ」が付く音
const YOUON: Record<string, string> = {
  ky: 'き', sy: 'し', ty: 'ち', ny: 'に', hy: 'ひ', my: 'み', ry: 'り',
  gy: 'ぎ', zy: 'じ', jy: 'じ', dy: 'ぢ', by: 'び', py: 'ぴ',
  sh: 'し', ch: 'ち', cy: 'ち', j: 'じ',
};
for (const [c, kana] of Object.entries(YOUON)) {
  TABLE[c + 'a'] = kana + 'ゃ';
  TABLE[c + 'u'] = kana + 'ゅ';
  TABLE[c + 'o'] = kana + 'ょ';
  TABLE[c + 'e'] = kana + 'ぇ';
}
Object.assign(TABLE, {
  // ヘボン式のつづりと、表にない音
  shi: 'し', chi: 'ち', tsu: 'つ', fu: 'ふ', ji: 'じ',
  ca: 'か', cu: 'く', co: 'こ',
  ya: 'や', yu: 'ゆ', yo: 'よ', ye: 'いぇ',
  wa: 'わ', wi: 'うぃ', we: 'うぇ', wo: 'を',
  fa: 'ふぁ', fi: 'ふぃ', fe: 'ふぇ', fo: 'ふぉ',
  va: 'ゔぁ', vi: 'ゔぃ', vu: 'ゔ', ve: 'ゔぇ', vo: 'ゔぉ',
  thi: 'てぃ', dhi: 'でぃ', twu: 'とぅ', dwu: 'どぅ',
  // 小さい文字
  xya: 'ゃ', xyu: 'ゅ', xyo: 'ょ', lya: 'ゃ', lyu: 'ゅ', lyo: 'ょ',
  xtu: 'っ', ltu: 'っ', xtsu: 'っ', ltsu: 'っ', xwa: 'ゎ', lwa: 'ゎ',
  // 「ん」と記号
  nn: 'ん', "n'": 'ん', xn: 'ん',
  '-': 'ー', ',': '、', '.': '。',
});

const KEYS = Object.keys(TABLE);
const MAX_LEN = Math.max(...KEYS.map((k) => k.length));
const isConsonant = (c: string) => /^[bcdfghjklmpqrstvwxyz]$/.test(c);

// final が false のときは、末尾の打ちかけのローマ字(「k」「sh」「n」など)を残す
export function toHiragana(text: string, final = false): string {
  let out = '';
  let i = 0;
  while (i < text.length) {
    const rest = text.slice(i).toLowerCase();

    // 表にある、いちばん長いつづりを探す
    let hit = '';
    for (let len = Math.min(MAX_LEN, rest.length); len > 0; len--) {
      if (TABLE[rest.slice(0, len)]) {
        hit = rest.slice(0, len);
        break;
      }
    }
    if (hit) {
      out += TABLE[hit];
      i += hit.length;
      continue;
    }

    const [c, next] = rest;
    // n のあとに母音・y・n・' 以外が来たら、その n は「ん」
    if (c === 'n' && next !== undefined && !/[aiueoyn']/.test(next)) {
      out += 'ん';
      i += 1;
      continue;
    }
    // 同じ子音が2つ続いたら「っ」(kka → っか)。tch も「っ」にする(matcha → まっちゃ)
    if (isConsonant(c) && c !== 'n' && (next === c || rest.startsWith('tch'))) {
      out += 'っ';
      i += 1;
      continue;
    }
    // 末尾の打ちかけは、そのまま残す(final のときの n だけは「ん」にする)
    if (KEYS.some((k) => k.length > rest.length && k.startsWith(rest))) {
      out += final && rest === 'n' ? 'ん' : text.slice(i);
      break;
    }
    // 表にない文字(数字、漢字、変換できないつづり)は、そのまま
    out += text[i];
    i += 1;
  }
  return out;
}

表は、行ごとの「かきくけこ」と母音の「aiueo」を組み合わせて作り、そこに「shi」「chi」「tsu」のようなヘボン式のつづりや、「fa」「thi」などを足しています。訓令式の「si」「ti」「tu」「hu」も、行の組み合わせから表に入ります(「ti」は「ち」、「hu」は「ふ」)。

変換は、文字列の先頭から、表にあるいちばん長いつづりを探して置き換えていきます。「kyo」は「ky」+「o」ではなく3文字のまま表に当たるので「きょ」になり、「nya」も「n」+「ya」ではなく「にゃ」になります。大文字は小文字にしてから探すので、「NIHONGO」も「にほんご」になりました。

表で決まらない文字は、次の3つの決まりで扱います。

  • 「n」のあとに母音・「y」・「n」・「'」以外の文字が来たら、「n」を「ん」にする(「kanji」→「かんじ」)
  • 同じ子音が2つ続いたら、前の1つを「っ」にする(「kitte」→「きって」)。「tch」の「t」も「っ」にする(「matcha」→「まっちゃ」)
  • 文字列の末尾が、表のどれかのつづりの書き始め(「k」「sh」「xt」など)なら、変換せずに残す

どれにも当たらない文字(数字や、「q」のように表にないつづり)は、そのまま残します。最後の決まりで残した打ちかけの文字は、次の文字を打ったときに、あらためて変換されます。final を true にしたときだけ、末尾に残った「n」を「ん」にします。

「ん」の打ち方で、結果が変わる例

toHiragana を Node.js で動かすと、次のようになりました。

入力 結果
nihongo にほんご
shinbun しんぶn
shinbun(final を true) しんぶん
konnnichiha こんにちは
konnichiha こんいちは
kon'nichiha こんにちは
honya ほにゃ
honnya / hon'ya ほんや
chokore-to ちょこれーと
kotoba,bunshou. ことば、ぶんしょう。
Tokyo ときょ

この関数では「nn」を「ん」にしているので、「konnichiha」の「nn」が「ん」になり、残った「i」が「い」になります。「こんにちは」にするには、「n」を3つ打つか、「n'」で区切ります。「honya」が「ほにゃ」になるのも、「nya」が表に当たるためです。

「Tokyo」が「ときょ」になるのは、つづりに長音の「u」がないためです。この関数は、書かれたつづりのとおりに変換します。

カーソルより前だけを変換する入力欄

tsx
import { useId, useLayoutEffect, useRef, useState } from 'react';
import { toHiragana } from './romaji';

// ローマ字で打つと、ひらがなに変わる入力欄
export function RomajiInput({ label }: { label: string }) {
  const id = useId();
  const [value, setValue] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  // 変換したあとに、カーソルを置き直す位置
  const caretRef = useRef<number | null>(null);

  useLayoutEffect(() => {
    const el = inputRef.current;
    if (el && caretRef.current !== null) {
      el.setSelectionRange(caretRef.current, caretRef.current);
      caretRef.current = null;
    }
  });

  return (
    <div className="romaji">
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        ref={inputRef}
        type="text"
        autoComplete="off"
        value={value}
        onChange={(e) => {
          const el = e.target;
          const pos = el.selectionStart ?? el.value.length;
          // カーソルより前だけを変換し、後ろはそのまま残す
          const before = toHiragana(el.value.slice(0, pos));
          setValue(before + el.value.slice(pos));
          caretRef.current = before.length;
        }}
        onBlur={() => setValue(toHiragana(value, true))}
      />
    </div>
  );
}
css
.romaji {
  display: grid;
  gap: 6px;
  max-width: 360px;
  margin-bottom: 16px;
}
.romaji input {
  font-size: 1.25rem;
  padding: 6px 8px;
}

<input> には value と onChange を渡しているので、公式リファレンスでいう「制御された」入力欄になります。onChange では、selectionStart でカーソルの位置を取り、その前の部分だけを toHiragana に通します。後ろの部分は変換せずに、そのままつなぎます。

変換すると、カーソルより前の文字数が変わります(「ka」の2文字が「か」の1文字になる)。そこで、変換したあとの文字数を caretRef に入れておき、useLayoutEffect の中で setSelectionRange を呼んでカーソルを置き直します。useLayoutEffect の公式リファレンスにあるとおり、これはブラウザが画面を再描画する前に実行されます。

Playwright でキーを1つずつ送り、欄の文字とカーソルの位置を見ました。「shinbun」と打った直後の欄は「しんぶn」で、Tab キーで欄を離れると onBlur で final を true にして変換し、「しんぶん」になりました。「kitte」と打ってから Backspace を2回押すと、「きって」は「きっ」、「き」の順に1文字ずつ消えました。消えるのはひらがなの1文字で、打ったローマ字の1文字ではありません。

全体を変換すると、カーソルが末尾へ飛ぶ

比べるために、カーソルの位置を気にせず、欄の文字全体を変換する書き方も作りました。

tsx
import { useId, useState } from 'react';
import { toHiragana } from './romaji';

// 比べるための書き方:欄の文字全体を変換し、カーソルの位置は気にしない
export function NaiveRomajiInput({ label }: { label: string }) {
  const id = useId();
  const [value, setValue] = useState('');

  return (
    <div className="romaji">
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        type="text"
        autoComplete="off"
        value={value}
        onChange={(e) => setValue(toHiragana(e.target.value))}
      />
    </div>
  );
}

どちらの欄にも「aiu」と打ち、左矢印キーを2回押してカーソルを「あ」の後ろに戻してから、「kaki」と打ちました。

2つの入力欄が縦に並んでいる。上の「全体を変換する書き方」の欄は「あかいうき」、下の「カーソルより前だけ変換する書き方」の欄は「あかきいう」になっている
aiu と打ち、カーソルを「あ」の後ろに戻して kaki と打ったあと。上が全体を変換する書き方、下がカーソルより前だけを変換する書き方

全体を変換する書き方では、「k」を打った時点ではカーソルは「あk」の後ろ(2文字目の後ろ)にありました。「a」を打って「か」に変わると、欄は「あかいう」になり、カーソルは末尾(4文字目の後ろ)へ移りました。続けて打った「ki」は末尾に入り、欄は「あかいうき」になりました。

「k」だけのときは、変換しても欄の文字が変わらないので、カーソルは動きませんでした。この検証でカーソルが飛んだのは、onChange で入れた値が、打った直後の欄の文字と違ったときだけです。

カーソルより前だけを変換する書き方では、「a」を打ったあともカーソルは「か」の後ろに残り、欄は「あかきいう」になりました。同じように「あ」の後ろで「na」と打つと「あないう」になり、途中に打った「n」も、次の「a」とまとまって「な」になりました。

貼り付けのように、一度に文字が入る場合も確かめました。Playwright の fill で「toukyouto」を入れると、欄は「とうきょうと」になり、カーソルは末尾にありました。

​Posted by 86