ブログ一覧へ

MathLiveで数式入力チュートリアルを作る

StepMath の数学入力ページでは、学習者が MathLive の標準キーボードを実際に操作しながら、解の公式や展開公式のような数式を入力します。


この記事では、実装の考え方を Web エンジニア向けに解説します。ソースコードは公開していないため、特定のファイル構成ではなく、MathLive を React アプリに組み込むときの設計、イベント制御、チュートリアル進行判定の仕組みに絞って説明します。

なぜMathLiveを使うのか

数式入力 UI を自前で作る場合、見た目の問題だけでなく、カーソル移動、分数の分子・分母、平方根、添字、仮想キーボード、コピー・貼り付けなどをすべて扱う必要があります。

StepMath では、数式編集の本体に MathLive を使っています。MathLive は <math-field> という Web Component を提供しており、ユーザーが入力した数式を LaTeX として取得できます。

基本形は次のようなイメージです。

<math-field math-virtual-keyboard-policy="manual"></math-field>

アプリ側では、入力欄の表示そのものを MathLive に任せ、学習サービスとして必要な次の処理を React 側で重ねています。

  • 入力値を LaTeX として state に同期する
  • 仮想キーボードを明示的に開閉する
  • 入力の進み具合を判定する
  • 次に押すキーを画面上で案内する
  • 完成した式をプレビューする

ReactからWeb Componentを扱う

<math-field> は通常の <input> ではありません。React の controlled input と同じ感覚で value を渡すだけでは、期待通りに同期できないことがあります。

そのため、アプリ側では MathLive の API を使って値を読み書きします。

function getLatex(field: any) {
  if (!field) return "";
  if (typeof field.getValue === "function") {
    return field.getValue("latex");
  }
  return field.value ?? "";
}

function setLatex(field: any, value: string) {
  if (!field) return;
  if (typeof field.setValue === "function") {
    field.setValue(value);
  } else {
    field.value = value;
  }
}

また、MathLive はブラウザ上で動く Web Component なので、Next.js や React の SSR 環境では初期化タイミングに注意が必要です。実装ではクライアント側で MathLive を読み込み、custom element の定義完了後に初期値を同期します。

useEffect(() => {
  let cancelled = false;

  import("mathlive").then(async () => {
    await customElements.whenDefined("math-field");
    if (cancelled) return;

    const field = inputRef.current;
    if (field) setLatex(field, currentLatex);
  });

  return () => {
    cancelled = true;
  };
}, []);

このように、MathLive の状態と React の状態を明示的に橋渡しするのが基本方針です。

仮想キーボードはmanualで制御する

StepMath のチュートリアルでは、「入力欄を選ぶ」「仮想キーボードを開く」「式を入力する」「仮想キーボードを閉じる」という操作そのものを教材にしています。

そのため、MathLive の仮想キーボードは自動表示に任せず、math-virtual-keyboard-policy="manual" にしています。

<math-field math-virtual-keyboard-policy="manual"></math-field>

キーボードを開くときは、入力欄へ focus した上で window.mathVirtualKeyboard.show() を呼びます。

function showKeyboard(field: any) {
  field?.focus({ preventScroll: true });

  const keyboard = window.mathVirtualKeyboard;
  if (keyboard) {
    keyboard.show?.({ animate: true });
    keyboard.update?.(field);
  } else {
    field?.executeCommand?.("showVirtualKeyboard");
  }
}

閉じるときは hide() を呼びます。

window.mathVirtualKeyboard?.hide?.({ animate: true });

この制御により、チュートリアル側は keyboard.visible を見て「今キーボードが開いているか」を判定できます。

標準アイコンではなく外部ボタンから開く

MathLive の入力欄には標準のキーボードアイコンやメニューアイコンがあります。しかし StepMath では、それらをそのまま使わず、入力欄の横に外部ボタンを置いています。

理由は3つあります。

  • タップ領域を十分に確保したい
  • チュートリアルでハイライトする対象を安定させたい
  • pointer event と focus の順序をアプリ側で制御したい

外部ボタンでは pointerdown の段階で preventDefault() し、入力欄の focus が外れないようにしてからキーボードを開きます。

function onKeyboardButtonPointerDown(event: PointerEvent) {
  event.preventDefault();
  event.stopPropagation();

  const field = inputRef.current;
  field?.focus({ preventScroll: true });
  showKeyboard(field);
}

クリック後に focus が外れると、MathLive 側がキーボードを閉じたり、入力対象を見失ったりすることがあります。pointerdown で先に処理するのは、その不安定さを避けるためです。

メニューも同様に外部ボタンから開きます。MathLive の showMenu() が使える場合はボタン付近の座標を渡し、失敗した場合は contextmenu イベントへ fallback します。

チュートリアルは状態機械として作る

StepMath のチュートリアルは、単に完成式と入力値を比較しているだけではありません。ユーザーの操作を段階的に検知する状態機械として作っています。

たとえば解の公式なら、次のようなステップになります。

  1. 入力欄が選択されたか
  2. 仮想キーボードが開いたか
  3. x= まで入力されたか
  4. 分数テンプレートが入ったか
  5. 分子に -b± が入ったか
  6. 平方根の中が b^2-4ac になったか
  7. 分母が 2a になったか
  8. 仮想キーボードが閉じられたか

進行判定は、おおむね次のような関数で表現できます。

function shouldAdvance({
  stepIndex,
  latex,
  inputFocused,
  keyboardVisible,
}: {
  stepIndex: number;
  latex: string;
  inputFocused: boolean;
  keyboardVisible: boolean;
}) {
  const normalized = normalizeLatex(latex);

  switch (stepIndex) {
    case 0:
      return inputFocused;
    case 1:
      return keyboardVisible;
    case 2:
      return /^x=/.test(normalized);
    case 3:
      return normalized.includes("\\frac");
    case 4:
      return getFractionNumerator(normalized).includes("-b\\pm");
    case 5:
      return getSqrtContent(normalized) === "b^2-4ac";
    case 6:
      return normalizeLatex(latex) === normalizeLatex("x=\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}");
    case 7:
      return !keyboardVisible;
    default:
      return false;
  }
}

ポイントは、途中状態を細かく見ることです。最終式だけを比較すると、どの操作でつまずいたのか分かりません。チュートリアルとしては「次に何をすればよいか」を出す必要があるため、各ステップで必要な部分式を判定しています。

LaTeXは正規化してから比較する

MathLive が返す LaTeX は、ユーザーの見た目が同じでも文字列として完全一致しないことがあります。

たとえば、次のような違いが出ます。

  • \left / \right が付く
  • \dfrac\tfrac が返る
  • 指数が ^{2} になる
  • 添字が _{i} になる
  • プレースホルダーが混ざる
  • 空白コマンドが入る

そのため、比較前に正規化します。

function normalizeLatex(value: string) {
  return String(value || "")
    .replace(/\\left/g, "")
    .replace(/\\right/g, "")
    .replace(/\\dfrac/g, "\\frac")
    .replace(/\\tfrac/g, "\\frac")
    .replace(/\\placeholder\{[^}]*\}/g, "")
    .replace(/\^\{([0-9a-zA-Z])\}/g, "^$1")
    .replace(/_\{([0-9a-zA-Z])\}/g, "_$1")
    .replace(/\s+/g, "");
}

数式入力の判定では、「文字列として同じか」よりも「数学的な入力意図として同じか」を見る必要があります。StepMath では、完全な数式処理系を作るのではなく、チュートリアル対象の式に必要な範囲で正規化と部分判定を行っています。

次に押すキーを案内する

チュートリアルでは、現在の入力状態から「次に押すべきキー」を決めます。

たとえば -b± を入力するステップなら、内部的には次のような順序を持っています。

const sequence = [
  { label: "−", done: (latex) => numerator(latex).includes("-") },
  { label: "b", done: (latex) => numerator(latex).includes("-b") },
  { label: "±", done: (latex) => numerator(latex).includes("\\pm") },
];

const next = sequence.find((item) => !item.done(currentLatex));

この考え方にすると、ユーザーが - まで入力したら次は b-b まで入力したら次は ± と案内できます。

また、正常な途中入力をエラー扱いしないことも重要です。-b± のステップで現在 -b なら、それは途中まで正しい状態です。赤く警告するべきではありません。

StepMath では、後続の条件だけが満たされていて前の条件が満たされていない場合だけ、「順序が崩れている可能性がある」と見なします。

MathLiveのキーボード上で対象キーを探す

次に押すキーが決まったら、画面上の MathLive 仮想キーボードから該当するキーを探してハイライトします。

MathLive の仮想キーボードは内部 DOM を持っているため、実装では表示中のキーボード領域を探し、その中の要素を走査します。キーの判定には、表示テキストだけでなく aria-label や class token も使います。

function describeKey(element: Element) {
  return {
    aria: element.getAttribute("aria-label") ?? "",
    text: element.textContent?.replace(/\s+/g, " ").trim() ?? "",
    className: element.getAttribute("class") ?? "",
  };
}

function isPlusKey(key: ReturnType<typeof describeKey>) {
  return key.aria === "+" || key.text === "+";
}

表示テキストだけに依存しないのは、MathLive のキー表示と aria-label が一致しないケースがあるためです。たとえば分数キーは見た目が ÷ でも、内部的には \frac に関係する情報を持つことがあります。

レイヤー切り替えもユーザー操作として教える

MathLive の仮想キーボードには、123abc、記号系レイヤー、Shift などがあります。対象キーが現在のレイヤーにない場合、アプリが勝手に切り替えることも技術的には可能です。

しかし StepMath では、自動切り替えはしません。

代わりに、まず押すべきレイヤータブや Shift キーをハイライトします。

まず 123 を押す
まず ⇧ を押す
次に押すキー:±

これは教材としての判断です。チュートリアル専用の近道を作ってしまうと、学習者が通常の入力画面で同じ操作を再現できません。標準キーボードの操作をそのまま覚えてもらうため、レイヤー切り替えも明示的なユーザー操作として扱っています。

ガイドUIはキー操作を邪魔してはいけない

チュートリアルでは、説明カード、入力欄のハイライト、キーのハイライトを同時に表示します。ここで注意すべきなのは、説明カードが押すべきキーを覆わないことです。

実装では、次の領域を見ながらカード位置を決めています。

  • 入力欄や外部ボタンの位置
  • MathLive 仮想キーボードの位置
  • 次に押すキーの位置
  • viewport のサイズ

キーボード表示中は画面下部を MathLive が占有します。説明カードがそこに重なると、カードがクリックを奪ってしまい、ユーザーがキーを押せません。

そのため、カード候補位置を複数作り、キーボード領域やハイライト領域と重ならない場所を選びます。ハイライト自体は pointer-events: none にして、見た目だけの補助にします。

コピー・貼り付けは別経路として扱う

数式入力では、貼り付け対応も意外と重要です。ブラウザの Clipboard API は権限や環境に左右されますし、MathLive のメニューから Paste が呼ばれる場合もあります。

StepMath では、貼り付けられた内容を次のような順で LaTeX として扱える形に寄せています。

  • application/x-latex
  • text/plain
  • text/html 内の MathML annotation
  • アプリ内に保持した fallback 文字列

直接クリップボードを読めない場合は、貼り付け専用の textarea を一時的に表示し、そこに貼り付けてもらう fallback を用意します。

チュートリアルの主経路はキーボード操作ですが、実際の入力 UI としてはコピー・貼り付けも壊れないようにしておく必要があります。

テストは「値を入れるテスト」と「実際に押すテスト」を分ける

この種の機能は、入力欄に直接値をセットするだけのテストでは不十分です。

StepMath では、テストの観点を分けています。

1つ目は、進行ロジックのテストです。入力値やキーボード表示状態を作り、ステップが正しく進むかを確認します。これは状態機械の退行検知に向いています。

2つ目は、実際に MathLive の仮想キーボードをクリックするテストです。チュートリアルがハイライトしたキーの位置をクリックし、レイヤー切り替えや Shift も含めて完走できるかを確認します。

後者では、setValue() で完成式を直接入れることはしません。ユーザー操作で本当に到達できるかを見るためです。

まとめ

StepMath の数学入力チュートリアルは、MathLive を単に埋め込んだだけの UI ではありません。

設計の中心は、次の3点です。

  • MathLive の標準入力体験を壊さずに使う
  • React 側で入力値、キーボード状態、カーソル状態を観測する
  • 完成式ではなく、そこに至る操作プロセスをチュートリアルとして扱う

数式入力 UI では、最終的な LaTeX 文字列だけを見れば十分に思えるかもしれません。しかし学習サービスでは、ユーザーがその文字列にどう到達したかが重要です。

StepMath では、MathLive の標準キーボードを使いながら、入力欄の focus、仮想キーボードの開閉、LaTeX の正規化、キー探索、ハイライト配置、E2E テストを組み合わせて、「操作を学べる数式入力」を実装しています。