見出し画像

創作大賞2026 #オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方

文字を「書く」ことは、ずっと当たり前だと思っていました。
失う、その日までは。

ここ最近、体調を崩し、思うように体を動かせない日々が続いていました。特に辛かったのは、右腕が曲がらなくなってしまったこと。

利き手である右手が使えなくなり、
文字を書くことも、キーボードで文字を打つことも、
思うようにできません。


日常の何気ない動作が次々と難しくなり、
当たり前にできていたことが、
こんなにも大切だったのだと初めて気づかされました。


慣れない左手でのタイピングは、
想像以上に時間がかかり、どっと疲れてしまいます。

スマホの音声入力に頼ってみても、
小説のような長文を綴るのは、至難の業…💦


「今年の『創作大賞2026』への新規応募は、
もう諦めるしかないのかな……。」

そう落ち込んでいました。


創作大賞2026オールカテゴリ部門【完全無料・神アプリ】多機能デジタル文字練習帳アプリ📱iPad・スマホ・PCでペン字練習🖊 #美文字 #アプリ #ai #Gemini #canvas


日本語版↓

創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="326">
デジタル文字練習帳

創作大賞2026 #オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 |🐰Koroちゃん🌸兎耳コロ💖創作大賞2026💎光片-ヒカリノカケラ-Hikari no Kakera


英語版↓ ※AIによる自動翻訳note記事

Creative Awards 2026 #AllCategoriesSection 💡 Born from the despair of my right arm not moving. A digital handwriting practice book created with AI that is "easy to write with the left hand" 🖊 A multi-functional web app that runs on iPad! Supports Hiragana, Katakana, Romaji, and numbers #PlayWithAI #WhenIFeelMyChildGrowing #StartedWithAI #HowToOvercomeASlump|🐰Koroちゃん🌸兎耳コロ💖創作大賞2026💎光片-ヒカリノカケラ-Hikari no Kakera
DIGITAL HANDWRITING PRACTICE NOTEBOOK

Creative Awards 2026 #AllCategoriesSection 💡 Born from the despair of my right arm not moving. A digital handwriting practice book created with AI that is "easy to write with the left hand" 🖊 A multi-functional web app that runs on iPad! Supports Hiragana, Katakana, Romaji, and numbers #PlayWithAI #WhenIFeelMyChildGrowing #StartedWithAI #HowToOvercomeASlump|🐰Koroちゃん🌸兎耳コロ💖創作大賞2026💎光片-ヒカリノカケラ-Hikari no Kakera




🔥 創作の灯を絶やしたくなくて

それでも諦めきれなかったのは、
私には大切な作品と、
それを支えてくれる人たちがいるから
です。


Shionちゃんのスタンプ - LINE スタンプ | LINE STORE


昨年、エンタメ原作部門で応募した小説
光片-ヒカリノカケラ-

今年はカテゴリーを変更し、
ファンタジー小説部門TALESにて
再び応募させていただきました🎊

🖊 小説「光片-ヒカリノカケラ-

📚マガジンはこちら
🔗 創作大賞2026💎光片-ヒカリノカケラ-|note

この作品は、昨年なんと
10万字を超える感想をいただき、
たくさんの方にファンアートを描いていただいた、
私の宝物のような作品です💎

さらに、映画化を目指して初の海外メンバーと協力し、
アニメ映像化(スペシャルメドレーMV)まで
果たすことができました🛫


💬 感想一覧&ファンアート特集

💌10万字の愛が詰まった感想まとめ
🔗 【創作大賞感想】作品案内&皆様の感想一覧✨

🎨 海外雑誌風!ファンアート特集
🔗 AI ART TOKYO 2025 - Kawaii Wonderland

🎬 アニメ映像化(YouTube)
初の海外チームとの共同制作!
日本語のスペシャルメドレーです♪
ぜひ、ご覧ください🐇

🎵光片-ヒカリノカケラ-再生リスト

総特集💫光片ヒカリノカケラ💎
Hikari no Kakera🪄Fragments of Light💍 - YouTube


▶️ Hikari no Kakera – The Miracle Answer | Cinematic Anime Music Video


▶️ Hikari no Kakera MV: ~ Dreams of Parallel Lines ~



💡 逆境から生まれた「左手のための」アプリ

創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="326">
創作大賞2026 #オールカテゴリ部門 💡
右腕が動かない絶望から生まれた。
AIと創った「左手でも書きやすい」デジタル文字練習帳🖊
iPadで動く多機能WEBアプリ!
ひらがな・カタカナ・ローマ字・数字対応

新作小説の執筆は難しい。
でも、今の私だからこそ生み出せるものがあるはず。

そう考えた私は、
「AIを駆使して、今の自分の不便さを
打開するアプリを作ろう!」
と決意しました。

そうして誕生したのが、今回
【創作大賞2026オールカテゴリ部門】で応募する、
ブラウザ上で動く
多機能デジタル文字練習帳アプリです。


私と同じように「左手」など片手で文字を書く苦労をしている方、
「右手」で字を綺麗に書きたい(リハビリしたい)方、
文字を学び始めた子どもたちや外国人の方、
ペン字練習帳をデジタル化したい方へ。

やさしさと使いやすさを詰め込んだ、
5つの特徴をご紹介します!


多機能デジタル文字練習帳アプリのCM💕創作大賞2026,オールカテゴリ部門,多機能デジタル文字練習帳💕美文字,ペン字,ペン字練習,文字練習,手書き練習,なぞり書き,ひらがな練習,無料アプリ,神アプリ,便利アプリ,おすすめアプリ,学習アプリ,教育アプリ,知育アプリ,iPad,iPad活用法,iPad勉強法,Apple Pencil,スマホアプリ,Webアプリ,ブラウザアプリ,デジタル学習,AI,生成AI,Gemini,Google Gemini,AIアプリ,AI開発,アプリ開発,個人開発,ノーコード,バイブコーディング,ChatGPT,英語学習,日本語学習,ローマ字練習,リハビリ,作業療法,左利き,右利き,両利き,アクセシビリティ,バリアフリー,インクルーシブデザイン,EdTech
多機能デジタル文字練習帳アプリのCM
QRコードを読み込むと、このnoteにジャンプします🔗


🖊多機能デジタル文字練習帳アプリ

創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="85">
多機能ヘッダーボタン

ヘッダーの多機能ボタンから、あらゆる操作が直感的に行えます!

例えば、、、

  • ひらがな ⇔ カタカナ ⇔ ローマ字 ⇔ 数字 の切り替え

  • 左手用⇔右手用

  • 保存形式(PNG画像 ⇔ PDF)の選択

  • 言語変換(日本語⇔英語)

  • 音声読み上げ、ズーム操作など


創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="438">
アプリ画面例


📱 デジタル文字練習帳アプリの5大特徴

創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="413">
多機能デジタル文字練習帳アプリ 
5つの特徴

1. 「左利き」に完全対応したやさしいUI

最大のこだわりは「利き手切り替え機能」です。
「左手用(赤色テーマ)」に切り替えると、
書いている手で文字が隠れてしまわないように、
「お手本・なぞり書き・練習」のマス目の並び順が反転します。
左利きのお子様の学習にも最適です!

2. 豊富な文字種と「段階的」な学習ステップ

ただ五十音を並べるのではなく、
書きやすい文字から練習できる
「ステップアップ方式」を採用。

  • ひらがな・カタカナ
    1画の簡単な文字から複雑な文字へ。
    五十音表(縦横)も完備

  • ローマ字
    大文字・小文字、濁音・拗音から、
    「ヘボン式と訓令式の違い
    (パスポート表記と学校教育の違い)」
    といった
    実践的な内容までカバー

  • 数字
    算用数字(0〜9)と漢数字(一〜万、〇、円)

3. タップで発音!「音声読み上げ機能」

お手本の文字をタップすると、その文字を音声で読み上げます🎵

アルファベットはネイティブ風に、
ローマ字は日本語の音で読むなど細かく調整。

ページ全体を一気に読み上げるボタンもあり、
「耳」からの学習もサポートします。

4. デジタルならではの「書き込み&保存機能」

  • ペン&消しゴム
    iPad等のタブレットやPCから、直接画面に文字を書き込めます。

  • ズーム&移動(パン)
    小さい画面でも拡大して細かい練習が可能。

  • PDF / 画像(PNG)保存
    書いたページをまとめて保存!
    プリントアウトして「紙のドリル」として使うこともできます📖
    (PCやiPad、タブレットなどで保存しやすい画像保存付き)

5. 使う人に寄り添った「便利な切り替え機能」

  • ダークモード対応
    夜間の学習や目に配慮した黒背景モード。
    書いた線の色も自動で白黒反転します。

  • 日英バイリンガル対応
    ボタン一つでメニューを「日本語⇔英語」に切り替え可能!
    日本語を勉強中の外国人の方にもそのまま使えます。



📝 アプリの画面と使い方(WEB・HTML)

創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="465">
デジタル文字練習帳🖊
iPadで動く多機能WEBアプリ

‎Gemini - Google AI に直接アクセス

このアプリの最大のこだわりは、
両利き対応かつデジタルWEBアプリで、
何度でも繰り返しトレーニングに使えます!!

さらに、ただ、あいうえお順に、
五十音順に文字を並べるのではなく、
「書きやすい文字から少しずつ難易度を上げていく」
独自のステップアップ方式
を採用している点です。


🔘ヘッダーの解説

創作大賞2026  <a target=#オールカテゴリ部門 💡右腕が動かない絶望から生まれた。AIと創った「左手でも書きやすい」デジタル文字練習帳🖊iPadで動く多機能WEBアプリ!ひらがな・カタカナ・ローマ字・数字対応 #AIで遊ぼう #子どもの成長を感じるとき #AIと始めてみた #スランプの乗り越え方" width="620" height="353">
デジタル文字練習帳🖊iPadで動く多機能WEBアプリ
ヘッダーの解説

このヘッダーは、画像・資料の閲覧や編集、保存などを行うためのメニューです。

  1.  ひらがな/カタカナ切り替え 
    ひらがなとカタカナを相互に切り替えます。

  2.  ローマ字・アルファベット(セット)
    ローマ字表記とアルファベット表記をセットで表示します。

  3.  数字・漢数字(セット)
    数字と漢数字をセットで表示します。

  4.  利き手切り替え
    左手用(デフォルト)から右手用への切り替えができます

  5.  消去
    選択した部分や書き込みを消去します。

  6.  全消去
    画面上のすべての書き込みや入力内容を消去します。

  7.  保存形式(PNG / PDF)
    PNG または PDF 形式でダウンロード・保存できます。

  8.  保存(現在のページ)
    現在のページを画像ファイルとして保存します。

  9.  全保存(すべてのページ)
    すべてのページをまとめて画像ファイルとして保存します。

  10.  描画ツール
    ペン・消しゴム・手のひら(移動)を選択できます。

  11.  ページ移動
    ホーム(最初のページに戻る)・前へ・次へでページをめくります。

  12.  表示・ズーム
    ズームアウト・全画面表示・ズームインが利用できます。

  13.  音声再生
    音声の再生/停止を行います。

  14.  言語切り替え
    日本語と英語を切り替えできます。

  15.  表示方向切り替え
    縦書きと横書きの一覧表示を切り替えできます。

  16.  テーマ切り替え
    ライトモードとダークモードを切り替えできます。

ポイント

用途や学習スタイルに合わせて、表示モードやツールを切り替えることで、より使いやすくなります。


🌸 ひらがな(全8ページ)

いきなり難しい文字から書かせません!
筆運びの難易度順にスモールステップで進みます。


  • Step 1(超入門)
    1画でスッと書けるシンプルな線。
    し・つ・く・へ

  • Step 2(初級)
    単純なカーブと2画の文字。
    の・て・と・い・こ・り・ひ

  • Step 3(中級)
    折り返しや波打ちの動きを練習。
    う・え・そ・や・ゆ・よ・る・ろ・ん

  • Step 4(上級)
    線の交差や全体のバランスが求められる文字。
    か・き・け・さ・せ・た・ち・に・ふ・も・ら・わ・を

  • Step 5(最難関)
    ペン字で最もつまづきやすい、ぐるっと回る「結び」がある文字に挑戦!
    あ・お・す・な・ぬ・ね・は・ほ・ま・み・む・め・れ

  • Step 6・7
    総仕上げとして、「縦書き」「横書き」
    それぞれの五十音表を完備しています。




✨ カタカナ(全8ページ)

カタカナ特有の「直線」と「角度」を意識したカリキュラムです。

  • Step 1(超入門)
    シンプルな直線と払い。
    イ・ノ・ハ・ヘ・ト・ニ・フ

  • Step 2(初級)
    向きと角度が命になる、間違いやすい文字をマスター。
    シ・ツ・ソ・ン・ム・ヤ・ク

  • Step 3(中級)
    折れ、カドを学ぶ文字。
    ア・カ・コ・サ・セ・タ・テ・ナ・ネ・ラ・ロ

  • Step 4(上級)
    交差する線のバランスを学びます。
    マ・メ・キ・ケ・チ・ヌ・ス・ホ・モ・ヨ・ル・ワ

  • Step 5(最難関)
    空間の取り方が難しい文字で仕上げます。
    ウ・エ・オ・ヒ・ミ・ユ・リ・レ・ヲ

  • Step 6・7
    ひらがな同様、縦書き・横書きの五十音表を完備。



🔤 ローマ字・アルファベット(全10ページ)

基礎から実践的な表記ルールの違いまで、
これ一つで完璧に網羅できます!

  • Step 1〜2
    まずは英語の基本「大文字(A〜Z)」「小文字(a〜z)」を練習。

  • Step 3〜4
    「あ行〜わ行」の基本ローマ字。

  • Step 5
    「が〜ぱ行」の濁音・半濁音。

  • Step 6〜8
    「きゃ・ひゃ・ぎゃ」などの拗音(ようおん)を徹底練習。

  • Step 9〜10(超実践編!)
    ここが最大のポイント!
    「ヘボン式」と「訓令式」の違いを比較・解説しながら練習できます。

    • ヘボン式(SHI, CHI, TSU, FU)
      英語話者が発音しやすく、
      パスポートや駅名・道路標識で使われる実用的な書き方。

    • 訓令式(SI, TI, TU, HU)
      五十音表の規則に沿っており、
      日本の小学校の授業で習うことが多い書き方。

    • 「ふ」や「しゃ」「ちゃ」など、
      特に間違えやすい注意点も
      赤色と青色でわかりやすくハイライト解説しています。



🔢 数字・漢数字(全2ページ)

  • Step 1
    日常で最も使う「算用数字(0〜9)」

  • Step 2
    「漢数字(一〜万、〇、円)」
    漢数字のマスには「いち」「1000」などの
    読み方(ルビ)とアラビア数字が補助として表示されるため、
    日本語を勉強中の外国人の方にもとても親切な設計になっています。


※因みに、すべてのモードで、英語に言語を切り替えできます!
言語切り替えボタンは、音声ボタンの右です!


💕スライド解説

創作大賞2026オールカテゴリ部門【完全無料・神アプリ】多機能デジタル文字練習帳アプリ📱iPad・スマホ・PCでペン字練習🖊 #美文字 <a target=#アプリ #ai #Gemini #canvas Koroちゃん♥兎耳コロ Creative Award 2026【100% FREE】AI Handwriting Practice App for iPad, Phone & PC ✍️Japanese " width="620" height="349">
Creative Award 2026 All Category【100% FREE】
AI Handwriting Practice App for iPad, Phone & PC ✍️


 「誰もが表現できる未来へ」というメッセージを掲げたタイトルスライド。余白をたっぷりと活かした洗練されたデザインで、違いによって可能性が閉ざされない未来への願いを表現しています。
スライド 1:誰もが表現できる未来へ

もし、伝えたいことがあるのに伝えられない人がいるとしたら。
もし、学びたい気持ちがあるのに、その機会を十分に得られない人がいるとしたら。
私たちは、その現実に向き合いました
今日お話しするのは、単なるアプリの話ではありません
一人ひとりの可能性が、違いによって閉ざされない未来の話です。
多機能デジタル文字練習帳アプリは、その未来へ向かう小さな一歩として生まれました。


 「失われていた可能性」をテーマに、左利きや学習環境の違いといった見えにくい困難に焦点を当てています。落ち着いたトーンのミニマルな構成で、人ではなく環境を変えるべきだという問いかけを伝えます。
スライド 2:失われていた可能性

世の中には、見えにくい困難が数多く存在しています。
左利きであること。
言葉や文化の違いがあること。
身体機能や学習環境に違いがあること。
そのどれもが、本来の能力とは無関係です。
それなのに、多くの人が挑戦する前に壁にぶつかっています
私たちは、問いかけました
本当に変えるべきなのは、人ではなく環境ではないでしょうか。


「私たちの答え」として、AIの力で個性を引き出すツール「Inclusive AI Writing Canvas」のコンセプトを描いています。誰も取り残されない学習体験を目指すプロセスが、シンプルな図解で示されています。
スライド 3:私たちの答え

その問いへの答えとして生まれたのが、Inclusive AI Writing Canvasです。
AIの力を使うためのツールではありません。
人の力を引き出すために、AIを使うツールです。
一人ひとりの違いを弱みとして扱うのではなく、個性として受け入れる
そんな学びの場所を、デジタルの力で実現したいと考えました。
私たちが目指すのは、誰も取り残されない学習体験です。


紙に書くような温かさと、デジタルの利便性が融合する自然な学びの流れを描いています。テクノロジーの進化の中でも人間らしさを大切にする姿勢が、柔らかなビジュアルで表現されています。
スライド 4:デジタルと人間らしさの融合

テクノロジーが進化するほど、私たちは人間らしさを大切にしたいと考えています。
だからこのアプリは、紙に書く温かさを残しました。
思いついたことを自由に書く。
試してみる。
間違える。
そしてまた挑戦する。
その自然な学びの流れを壊さないことを、何よりも大切にしています。


左利きの方を含め、あらゆる人が我慢せずに使えるデザインの重要性を伝えています。無意識の前提を取り払い、誰かの挑戦を支えるUI設計の価値がすっきりとまとめられています。
スライド 5:たった一つの配慮が人生を変える

多くの人にとって当たり前の操作も、誰かにとっては使いづらさの原因になります。
例えば、左利きの方です。
世の中の多くのサービスは、無意識のうちに右利きを前提として設計されています。
しかし、私たちは考えました
誰かが我慢する前提のデザインは、本当に正しいのでしょうか。
わずかな配慮かもしれません。
けれど、その配慮が誰かの挑戦を支える力になるのです。


「記録は希望になる」というメッセージとともに、成果を保存し振り返る機能について紹介しています。過去の自分を見て前へ進む勇気を得るという温かいコンセプトが、洗練されたレイアウトで描かれています。
スライド 6:記録は希望になる

学びは、その瞬間だけのものではありません
今日できなかったことが、明日できるようになる。
その成長を見届けるためには、記録が必要です。
このアプリでは、成果を保存し、振り返ることができます。
過去の自分を見ることで、人は前へ進む勇気を得られます。
記録は、単なるデータではありません
未来への希望です。


 「書く・直す・保存する」という直感的な操作フローを、無駄のないシンプルなアイコンで図解しています。難しい操作を覚えるのではなく、自分らしく表現するためのデザインであることが伝わります。
スライド 7:挑戦を簡単にする

新しいことを始めるとき、人は不安を感じます
だから私たちは、操作をできる限りシンプルにしました。
書く。
直す。
保存する。
それだけです。
難しい操作を覚えるためではなく、自分らしく表現するために時間を使ってほしい
その想いを、この設計に込めています


文字と音を結びつけ、発音や読み方の理解を支える機能を説明しています。言葉の違いによる壁をなくし、学ぶ喜びを届けるという想いが、クリアなビジュアルで表現されています。
スライド 8:言葉の壁を越える

言葉は、人と人をつなぐ力です。
しかし、ときとして言葉は壁にもなります
発音が分からない。
読み方が分からない。
だから、自信を失ってしまう
そんな瞬間を、少しでも減らしたい
文字と音を結び付け、理解を支えることで、私たちは学ぶ喜びを届けたいと考えています。


数字などの表現を見比べながら学べる環境を、視覚的にわかりやすく整理しています。「分かった」という成功体験が次の挑戦へつながる様子を、圧迫感のないデザインで示しています。
スライド 9:理解の瞬間を増やす

学びの中には、最も美しい瞬間があります。
それは、分からなかったことが分かった瞬間です。
数字の表現一つを取っても、人によって理解しやすい形は異なります
だから私たちは、多様な表現を見比べながら学べる環境を用意しました。
理解できたという成功体験が、人を次の挑戦へ導いてくれるからです。


書く、話す、描くなど、人それぞれ得意な方法で入力できるインクルーシブなアプローチを描いています。ツールが人に合わせるという理念が、柔軟さを感じるレイアウトでまとめられています。
スライド 10:一人ひとりに合った表現方法

人それぞれ、得意なことは違います
文字を書くことが得意な人。
話して伝えることが得意な人。
絵や図で考えることが得意な人。
だから、入力方法も一つに限定しませんでした
人がツールに合わせるのではなく、ツールが人に合わせる
それこそが、本当のインクルーシブデザインだと考えています。


見えない場所で支える複雑なシステム構成を、スマートな図解で表現しています。高度な技術が裏方として静かに人を支えるという、洗練された設計思想が伝わります。
スライド 11:見えない場所で支える技術

どれほど優しい体験も、それを支える技術がなければ実現できません。
このシステムには、多くの工夫と技術が詰め込まれています。
しかし、利用者に複雑さを感じさせてはいけません
私たちが目指したのは、高度な技術が静かに人を支える世界です。
主役はシステムではありません。
利用する一人ひとりです。


日々の練習や上達の軌跡をしっかり保存し、振り返るための仕組みを描いています。見えにくい人の成長を可視化し、未来の財産として残すという温かいメッセージが込められています。
スライド 12:努力を未来へ残す

誰かが一生懸命書いた文字。
繰り返し練習した記録。
少しずつ上達した軌跡。
それらはすべて、その人だけの財産です。
だから私たちは、その努力をしっかり残せる仕組みを作りました。
人の成長は見えにくいものです。
だからこそ、残し、振り返り、実感できることが大切なのです。


どんな環境でも安心して使えるよう、ライトモードとダークモードの切り替えなどを美しく図解しています。機能の多さではなく、自分らしく快適に使える心地よさを表現しています。
スライド 13:誰もが心地よく使えるために

使いやすさとは、機能の多さではありません
安心して使えることです。
長時間使っても疲れないことです。
自分らしく使えることです。
ライトモードもダークモードも、そのために存在しています
どんな環境でも、どんな人でも。
快適に使える体験を目指しました。


リハビリを頑張る方、子どもたち、外国語を学ぶ方、左利きの方など、画面の向こうにいる多様なユーザーを温かみのある図でつないでいます。テクノロジーで多くの人に寄り添うビジョンが描かれています。
スライド 14:届けたい相手

私たちが見ているのは、画面の向こうにいる人です。
リハビリを頑張る方。
新しい言葉を学ぶ方。
初めて文字を書く子どもたち。
左利きだから不便を感じていた方。
その一人ひとりに寄り添いたい
テクノロジーの価値は、どれだけ多くの人を支えられるかで決まると信じています。


「未来へのメッセージ」を伝えるクロージングスライドです。誰もが自分らしく学び、表現できる社会への願いが、余韻を残すシンプルで美しいレイアウトの中に込められています。
スライド 15:未来へのメッセージ

誰もが、何かを伝えたいと思っています。
誰もが、学びたいと思っています。
誰もが、成長したいと思っています。
その願いに、違いや障壁があってはならない
私たちが作ったのは、アプリではありません
可能性をあきらめなくていいというメッセージです。
一人でも多くの人が、自分らしく学び、自分らしく表現できる社会へ。
多機能デジタル文字練習帳アプリは、その未来を信じて歩み続けます
ありがとうございました。



👇実際のコード(使い方)はこちら

以下に記載したHTMLコードをコピーして
メモ帳に貼り付けて、拡張子を.htmlで保存して開いて頂くか、
GeminiCanvasで作ったリンクをタップすると、使用できます🔗

上記のリンクを押して、WEBブラウザで開くと、
以下の画面が表示され、「続ける」のボタンをクリック!
※Geminiにログインしなくても、アプリを使用できます!!


例)ファイル名:japanese_practice_workbook.html
ひらがな&カタカナ&数字&ローマ字 練習帳(両利き・音声読み上げ対応).html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
    <title>ひらがな&カタカナ&数字&ローマ字 練習帳(両利き・音声読み上げ対応)</title>
    <link href="/https://fonts.googleapis.com/css2?family=Klee+One:wght@400;600&family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="/https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
    <script src="/https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <script src="/https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>

    <style>
        * { box-sizing: border-box; }
        body {
            margin: 0; 
            padding-top: calc(130px + env(safe-area-inset-top, 0px)); 
            font-family: 'Noto Sans JP', sans-serif;
            background-color: var(--body-bg); color: var(--text-color);
            transition: background-color 0.3s, color 0.3s;
            overflow: auto; touch-action: pan-x pan-y; 
        }

        /* ライトモード */
        :root {
            --body-bg: #e9ecef; --bg-color: #ffffff; --text-color: #212529;
            --border-color: #adb5bd; --grid-color: rgba(0,0,0,0.15);
            --trace-color: rgba(0,0,0,0.2); 
            --ctrl-bg: #ffffff; --block-bg: #f8f9fa; --cell-bg: #ffffff;
            --btn-hover: #f1f3f5;
        }

        /* 左手: 赤系 / 右手: 青系 */
        body.left-hand { --accent-color: #b52a2a; } 
        body.right-hand { --accent-color: #2a5eb5; }

        /* ダークモード */
        body.dark-mode {
            --body-bg: #121212; --bg-color: #1e1e1e; --text-color: #f8f9fa;
            --border-color: #495057; --grid-color: rgba(255,255,255,0.15);
            --trace-color: rgba(255,255,255,0.25); 
            --ctrl-bg: #212529; --block-bg: #2b2b2b; --cell-bg: #1e1e1e;
            --btn-hover: #343a40;
        }
        body.dark-mode.left-hand { --accent-color: #ff6b6b; }
        body.dark-mode.right-hand { --accent-color: #4a90e2; }

        body.move-mode { touch-action: none; }

        /* 左手用の場合はマス目の並び順を反転 */
        body.left-hand .char-block { flex-direction: row-reverse; }

        /* コントロールバー(セーフエリア対応) */
        #controls {
            position: fixed; top: 0; left: 0; width: 100%;
            background-color: var(--ctrl-bg); 
            padding: calc(12px + env(safe-area-inset-top, 0px)) 15px 12px;
            display: flex; flex-direction: column; gap: 12px;
            align-items: center; 
            z-index: 1000; box-shadow: 0 2px 10px rgba(0,0,0,0.15);
            border-bottom: 1px solid var(--border-color);
            transition: background-color 0.3s;
        }

        .control-row { 
            display: flex; justify-content: flex-start; align-items: center; 
            flex-wrap: nowrap; gap: 12px; 
            width: fit-content; max-width: 100%;
            overflow-x: auto; 
            -ms-overflow-style: none; scrollbar-width: none;
            padding-bottom: 2px;
        }
        .control-row::-webkit-scrollbar { display: none; }

        .tool-group { 
            display: flex; align-items: center; background: var(--block-bg); 
            padding: 4px; border-radius: 25px; border: 1px solid var(--border-color); 
            gap: 2px; white-space: nowrap; 
        }
        
        .btn { 
            padding: 8px 14px; border: none; border-radius: 20px; 
            background-color: transparent; color: var(--text-color); cursor: pointer; 
            font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px; 
            transition: all 0.2s; font-family: inherit; white-space: nowrap;
        }
        .btn:hover:not(:disabled) { background-color: var(--btn-hover); }
        .btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .btn.active { background-color: var(--accent-color); color: #fff; }
        .btn-primary { background-color: var(--accent-color); color: #fff; }
        .btn-primary:hover:not(:disabled) { filter: brightness(0.9); }
        .icon-btn { width: 40px; height: 40px; justify-content: center; border-radius: 50%; padding: 0; font-size: 16px; }
        .text-danger { color: #dc3545; } body.dark-mode .text-danger { color: #ff8787; }

        .format-select { padding: 8px 12px; border-radius: 20px; border: none; background-color: transparent; color: var(--text-color); font-weight: bold; font-family: inherit; outline: none; cursor: pointer; }
        .format-select option { color: #000; }
        .page-indicator { font-weight: bold; font-size: 16px; min-width: 60px; text-align: center; background: var(--block-bg); padding: 6px 16px; border-radius: 20px; border: 1px solid var(--border-color); white-space: nowrap; }

        #pages-container { display: flex; justify-content: center; padding: 20px; transform-origin: top center; transition: transform 0.1s ease-out; }

        /* ページサイズ (A4横) */
        .page {
            display: none; width: 1120px; height: 792px; min-width: 1120px;
            background-color: var(--bg-color); padding: 15px 30px;
            border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); 
            margin: 0 auto; position: relative; flex-direction: column;
        }
        .page.active { display: flex; }

        .main-title { font-size: 22px; margin: 0 0 6px; border-bottom: 2px solid var(--border-color); padding-bottom: 6px; display: flex; align-items: center; gap: 10px; }
        .step-title-small { font-size: 14px; font-weight: bold; margin: 2px 0; display: flex; align-items: center; gap: 8px; }
        .step-desc { font-size: 13px; line-height: 1.4; margin-bottom: 8px; opacity: 0.9; white-space: pre-wrap; }
        .step-badge { background-color: var(--accent-color); color: #fff; padding: 2px 10px; border-radius: 12px; font-size: 13px; font-weight: bold; }
        .step-badge.large { font-size: 16px; padding: 4px 12px; }

        .char-block { display: flex; background-color: var(--block-bg); border-radius: 8px; border: 1px solid var(--border-color); }
        
        .cell { position: relative; border: 1px solid var(--border-color); background-color: var(--cell-bg); display: flex; align-items: center; justify-content: center; font-family: 'Klee One', cursive; line-height: 1; flex-shrink: 0; }
        .cell.sample span.main-char { color: var(--text-color); font-weight: 600; }
        .cell.trace span.main-char { color: var(--trace-color); }
        .cell::before, .cell::after { content: ''; position: absolute; pointer-events: none; }
        .cell::before { top: 50%; left: 0; right: 0; border-top: 1px dashed var(--grid-color); }
        .cell::after { left: 50%; top: 0; bottom: 0; border-left: 1px dashed var(--grid-color); }
        canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; touch-action: none; }

        .cell.sample { cursor: pointer; transition: opacity 0.2s; }
        .cell.sample:active { opacity: 0.5; }

        .summary-wrapper { margin: 0 auto; width: max-content; display: flex; flex-direction: column; align-items: flex-start; }
        .blocks-container.compact { display: grid; grid-template-columns: repeat(7, max-content); justify-content: flex-start; gap: 6px 8px; margin-bottom: 6px; width: 100%; }
        .char-block.compact { gap: 2px; padding: 3px; }
        .cell.compact { width: 43px; height: 43px; font-size: 32px; } 

        .blocks-container.step-layout { display: grid; grid-template-columns: repeat(3, max-content); justify-content: center; gap: 20px 30px; margin-top: 20px; width: 100%; }
        .char-block.step { gap: 4px; padding: 6px; }
        .cell.step { width: 105px; height: 105px; font-size: 80px; } 

        .vertical-layout { display: flex; flex-direction: row-reverse; justify-content: center; gap: 14px; margin-top: 10px; }
        .column-group { display: flex; flex-direction: column; gap: 4px; }
        .horizontal-layout { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 4px; margin-top: 10px; }
        .row-group { display: flex; flex-direction: row; gap: 10px; }
        .char-block.aiueo { gap: 2px; padding: 3px; }
        .cell.aiueo { width: 54px; height: 54px; font-size: 40px; }

        .blocks-container.numbers-layout, .blocks-container.kanji-layout { display: grid; grid-template-columns: repeat(3, max-content); justify-content: center; gap: 20px 20px; margin-top: 30px; width: 100%; }
        .char-block.numbers-block, .char-block.kanji { gap: 6px; padding: 6px; }
        .cell.numbers-cell, .cell.kanji { width: 105px; height: 105px; font-size: 80px; }
        .cell.kanji { font-size: 64px; font-family: 'Klee One', cursive; }
        
        .kanji-ruby { position: absolute; top: 6px; left: 0; right: 0; text-align: center; font-size: 18px; font-family: 'Klee One', cursive; font-weight: bold; color: var(--accent-color); opacity: 0.85; pointer-events: none; line-height: 1; }
        body.dark-mode .kanji-ruby { color: var(--text-color); opacity: 0.7; }
        .kanji-num { position: absolute; bottom: 6px; left: 0; right: 0; text-align: center; font-size: 18px; font-family: 'Noto Sans JP', sans-serif; font-weight: bold; color: var(--accent-color); opacity: 0.85; pointer-events: none; line-height: 1; }
        body.dark-mode .kanji-num { color: var(--text-color); opacity: 0.7; }
        .cell.kanji span.main-char { margin-top: 0; }

        .blocks-container.alphabet-layout { display: flex; flex-direction: column; gap: 14px; align-items: center; margin-top: 20px; width: 100%; }
        .blocks-container.alphabet-layout .row-group { display: flex; justify-content: center; align-items: center; gap: 10px; width: 100%; }
        .char-block.alphabet { gap: 3px; padding: 3px; }
        .cell.alphabet { width: 44px; height: 44px; font-size: 32px; font-family: 'Noto Sans JP', sans-serif; }
        .char-block.romaji { gap: 4px; padding: 4px; }
        .cell.romaji { width: 64px; height: 64px; font-size: 24px; font-family: 'Noto Sans JP', sans-serif; letter-spacing: 0; }
        
        .romaji-kana { position: absolute; top: 3px; left: 0; right: 0; display: flex; justify-content: space-between; padding: 0 4px; font-size: 14px; font-family: 'Klee One', cursive; font-weight: bold; color: var(--accent-color); opacity: 0.85; pointer-events: none; line-height: 1; }
        body.dark-mode .romaji-kana { color: var(--text-color); opacity: 0.7; }
        
        .cell.alphabet span.main-char { font-family: 'Noto Sans JP', sans-serif; }
        .cell.romaji span.main-char { font-family: 'Noto Sans JP', sans-serif; margin-top: 14px; }

        .char-block.romaji-yoon { gap: 6px; padding: 6px; }
        .cell.romaji-yoon { width: 96px; height: 96px; font-size: 40px; font-family: 'Noto Sans JP', sans-serif; letter-spacing: 0; }
        .cell.romaji-yoon .romaji-kana { font-size: 16px; top: 6px; padding: 0 8px; }
        .cell.romaji-yoon span.main-char { margin-top: 16px; }

        /* ==== ヘボン式・訓令式の比較ページ用 CSS ==== */
        .diff-page-container { display: flex; gap: 20px; margin-top: 15px; height: calc(100% - 40px); align-items: stretch; }
        .diff-text-area { width: 380px; display: flex; flex-direction: column; gap: 12px; }
        .diff-card { background: var(--block-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px 16px; font-size: 14.5px; line-height: 1.5; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
        .diff-card p { margin: 0; }
        .diff-card h4 { margin: 0 0 8px; font-size: 16px; color: var(--accent-color); border-bottom: 1px dashed var(--border-color); padding-bottom: 6px; display: flex; align-items: center; gap: 6px; }
        .hebon-card h4 { color: #d35400; } .kunrei-card h4 { color: #2980b9; }
        body.dark-mode .hebon-card h4 { color: #e67e22; } body.dark-mode .kunrei-card h4 { color: #3498db; }
        
        /* ヘボン式ハイライト(赤系) */
        .diff-highlight { color: #e74c3c; font-weight: bold; }
        body.dark-mode .diff-highlight { color: #ff7675; }
        .cell.trace span.main-char .diff-highlight { color: inherit; } 

        /* 訓令式ハイライト(青系) */
        .kunrei-highlight { color: #2980b9; font-weight: bold; }
        body.dark-mode .kunrei-highlight { color: #3498db; }
        .cell.trace span.main-char .kunrei-highlight { color: inherit; } 

        .diff-note-list { margin: 0; padding-left: 20px; } .diff-note-list li { margin-bottom: 6px; }
        
        .diff-practice-area { flex: 1; background: var(--block-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px 16px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
        .diff-table-header { display: flex; align-items: center; border-bottom: 2px solid var(--border-color); padding-bottom: 6px; margin-bottom: 8px; font-weight: bold; font-size: 14px; text-align: center; color: var(--text-color); }
        .diff-table-header.small { font-size: 12px; }
        
        .diff-col-kana { width: 45px; text-align: center; font-size: 18px; font-family: 'Klee One', cursive; font-weight: bold; }
        .diff-col-hebon, .diff-col-kunrei { flex: 1; display: flex; justify-content: center; align-items: center; position: relative; }
        
        .diff-row { display: flex; align-items: center; margin-bottom: 6px; background: var(--cell-bg); padding: 6px; border-radius: 6px; border: 1px solid var(--border-color); }
        .char-block.diff-block { gap: 4px; padding: 0; border: none; background: transparent; }
        .cell.diff-cell { width: 52px; height: 52px; font-size: 20px; border-radius: 4px; font-family: 'Noto Sans JP', sans-serif; }
        .cell.diff-cell.small { width: 46px; height: 46px; font-size: 16px; }
        .cell.diff-cell span.main-char { margin-top: 0; } 

        /* ロード用オーバーレイ */
        #loading-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 9999; flex-direction: column; justify-content: center; align-items: center; color: white; text-align: center; }

        /* 画像保存(写真アプリ用)モーダル */
        #image-modal {
            display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.95); z-index: 10000; flex-direction: column;
        }
        .modal-header {
            display: flex; justify-content: space-between; align-items: center;
            padding: calc(15px + env(safe-area-inset-top, 0px)) 15px 15px; 
            background: #222; color: #fff; font-weight: bold;
        }
        .modal-content {
            flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 20px; align-items: center;
        }
        .modal-content img {
            max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            border: 2px solid #555;
        }
    </style>
</head>
<body class="dark-mode left-hand" data-tool="pen">

    <div id="loading-overlay">
        <i class="fa-solid fa-spinner fa-spin fa-4x"></i>
        <h2 style="margin-top: 20px;" data-i18n="common.loading_title">保存処理中...</h2>
        <p data-i18n="common.loading_desc"></p>
    </div>

    <!-- 写真アプリ保存用モーダル -->
    <div id="image-modal">
        <div class="modal-header">
            <span data-i18n="common.modal_guide">画像を長押しして「写真に追加」または「保存」を選んでください。</span>
            <button class="btn icon-btn text-danger" onclick="closeModal()"><i class="fa-solid fa-xmark"></i></button>
        </div>
        <div class="modal-content" id="modal-images"></div>
    </div>

    <!-- コントロールバー -->
    <div id="controls">
        <div class="control-row">
            <div class="tool-group">
                <button class="btn btn-primary" onclick="setScriptMode('kana')" title="ひらがな/カタカナ" style="min-width: 90px; justify-content: center; background-color: #2b908f;">
                    <span id="kana-btn-text">ひらがな</span>
                </button>
                <button class="btn btn-primary" onclick="setScriptMode('alphabet')" title="ローマ字" style="min-width: 90px; justify-content: center; background-color: #8e44ad;">
                    <span data-i18n="common.script_alphabet">ローマ字</span>
                </button>
                <button class="btn btn-primary" onclick="setScriptMode('numbers')" title="数字" style="min-width: 90px; justify-content: center; background-color: #d35400;">
                    <span data-i18n="common.script_numbers">数字</span>
                </button>
                <button class="btn btn-primary" id="hand-toggle-btn" onclick="toggleHand()" title="利き手切り替え" style="min-width: 100px; justify-content: center;">
                    <i class="fa-solid fa-hand"></i> <span>左手用</span>
                </button>
            </div>
            
            <div class="tool-group">
                <button class="btn" onclick="clearCurrentPage()"><i class="fa-regular fa-trash-can"></i> <span data-i18n="common.btn_clear_page">ページ消去</span></button>
                <button class="btn text-danger" onclick="clearAllPages()"><i class="fa-solid fa-trash-can"></i> <span data-i18n="common.btn_clear_all">全消去</span></button>
            </div>
            
            <div class="tool-group">
                <select id="save-format" class="format-select">
                    <option value="png">PNG</option>
                    <option value="pdf">PDF</option>
                </select>
                <button class="btn btn-primary" onclick="saveCurrentPage()"><i class="fa-solid fa-download"></i> <span data-i18n="common.btn_save_page">保存</span></button>
                <button class="btn btn-primary" onclick="saveAllPages()"><i class="fa-solid fa-file-zipper"></i> <span data-i18n="common.btn_save_all">全保存</span></button>
            </div>
        </div>
        
        <div class="control-row">
            <div class="tool-group">
                <button class="btn active icon-btn" id="btn-pen" onclick="setTool('pen')" title="Pen"><i class="fa-solid fa-pen"></i></button>
                <button class="btn icon-btn" id="btn-eraser" onclick="setTool('eraser')" title="Eraser"><i class="fa-solid fa-eraser"></i></button>
                <button class="btn icon-btn" id="btn-move" onclick="setTool('move')" title="Move/Pan"><i class="fa-solid fa-hand"></i></button>
            </div>
            
            <div class="tool-group nav-group" style="padding: 4px;">
                <button class="btn" onclick="goToTop()" title="Home">
                    <i class="fa-solid fa-house"></i>
                </button>
                <button class="btn" id="prev-btn" onclick="changePage(-1)">
                    <i class="fa-solid fa-chevron-left"></i> <span data-i18n="common.btn_prev">前へ</span>
                </button>
                <span class="page-indicator" id="page-indicator" style="border: none; background: transparent;">1 / 8</span>
                <button class="btn" id="next-btn" onclick="changePage(1)">
                    <span data-i18n="common.btn_next">次へ</span> <i class="fa-solid fa-chevron-right"></i>
                </button>
            </div>

            <!-- ズーム操作コントロールを追加 -->
            <div class="tool-group">
                <button class="btn icon-btn" onclick="changeZoom(-0.1)" title="縮小"><i class="fa-solid fa-magnifying-glass-minus"></i></button>
                <button class="btn icon-btn" onclick="resetZoom()" title="100%" style="font-size: 14px;"><i class="fa-solid fa-expand"></i></button>
                <button class="btn icon-btn" onclick="changeZoom(0.1)" title="拡大"><i class="fa-solid fa-magnifying-glass-plus"></i></button>
            </div>

            <div class="tool-group">
                <button class="btn icon-btn" onclick="speakCurrentPage()" title="ページを読み上げる"><i class="fa-solid fa-volume-high"></i></button>
                <button class="btn icon-btn text-danger" onclick="stopSpeaking()" title="読み上げを停止する"><i class="fa-solid fa-stop"></i></button>
            </div>
            
            <div class="tool-group">
                <button class="btn icon-btn" onclick="toggleLang()" title="Language Switch"><i class="fa-solid fa-language"></i></button>
                <button class="btn icon-btn jump-btn" onclick="jumpToAiueoV()" title="縦書き" style="gap: 2px;">
                    <i class="fa-solid fa-arrow-down" style="font-size: 12px;"></i>
                    <span id="jump-v-text" style="font-size: 12px; font-weight: bold; font-family: 'Noto Sans JP';">あ</span>
                </button>
                <button class="btn icon-btn jump-btn" onclick="jumpToAiueoH()" title="横書き" style="gap: 2px;">
                    <i class="fa-solid fa-arrow-right" style="font-size: 12px;"></i>
                    <span id="jump-h-text" style="font-size: 12px; font-weight: bold; font-family: 'Noto Sans JP';">あ</span>
                </button>
                <button class="btn icon-btn" onclick="toggleTheme()" title="Theme"><i id="theme-icon" class="fa-solid fa-sun"></i></button>
            </div>
        </div>
    </div>

    <div id="pages-container"></div>

    <script>
        // iOS/iPadOS判定(長押し保存モード用)
        const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

        let currentLang = 'jp';
        let currentHand = 'left';    
        let currentScript = 'hiragana';
        let lastKana = 'hiragana';
        let lastKanaPage = 1;            
        let currentPage = 1;
        let currentTool = 'pen';
        let penSize = 3;
        let eraserSize = 50;
        let currentZoom = 1.0;
        let lastSpokenChar = '';
        let lastSpokenTime = 0;

        const i18n = {
            jp: {
                common: {
                    btn_clear_page: "消去", btn_clear_all: "全消去", btn_save_page: "保存", btn_save_all: "全保存", btn_prev: "前へ", btn_next: "次へ",
                    loading_title: "保存処理中...", loading_desc: "※全ページ保存には少し時間がかかります。<br>ブラウザから<b>「複数ファイルのダウンロードを許可しますか?」</b>と聞かれた場合は、<b>「許可」</b>を押してください。",
                    script_hiragana: "ひらがな", script_katakana: "カタカナ", script_alphabet: "ローマ字", script_numbers: "数字", hand_left: "左手用", hand_right: "右手用",
                    kana: "かな", hebon: "ヘボン式", kunrei: "訓令式",
                    modal_guide: "画像を長押しして「写真に追加」または「画像を保存」を選んでください。"
                },
                hiragana: {
                    title_main: { left: "左手用 ひらがな練習帳", right: "右手用 ひらがな練習帳" }, title_aiueo_v: "縦書き 五十音順(あ〜わ行)", title_aiueo_h: "横書き 五十音順(あ〜わ行)",
                    step1_title: "1画のシンプルな線(超入門)", step2_title: "単純なカーブと2画の文字(初級)", step3_title: "折り返しと波打ち(中級)", step4_title: "線の交差とバランス(上級)", step5_title: "ぐるっと回る「結び」(最難関)"
                },
                katakana: {
                    title_main: { left: "左手用 カタカナ練習帳", right: "右手用 カタカナ練習帳" }, title_aiueo_v: "縦書き 五十音順(ア〜ワ行)", title_aiueo_h: "横書き 五十音順(ア〜ワ行)",
                    step1_title: "シンプルな直線と払い(超入門)", step2_title: "向きと角度が命(初級)", step3_title: "折れとカド(中級)", step4_title: "交差とバランス(上級)", step5_title: "空間の取り方と形(最難関)"
                },
                alphabet: {
                    title_main: { left: "左手用 アルファベット・ローマ字", right: "右手用 アルファベット・ローマ字" },
                    step1_title: "アルファベット 大文字 (A〜Z)", step2_title: "アルファベット 小文字 (a〜z)", step3_title: "ローマ字 (あ行〜な行)", step4_title: "ローマ字 (は行〜わ行)", step5_title: "ローマ字 濁音・半濁音 (が〜ぱ行)", step6_title: "ローマ字 拗音1 (きゃ〜にゃ)", step7_title: "ローマ字 拗音2 (ひゃ〜りゃ)", step8_title: "ローマ字 拗音3 (ぎゃ〜ぴゃ)",
                    step9_title: "ヘボン式と訓令式の違い(子音)", step10_title: "ヘボン式と訓令式の違い(拗音)",
                    diff_hebon_title: "<i class=\"fa-solid fa-passport\"></i> ヘボン式",
                    diff_hebon_desc: "英語話者が発音しやすい書き方。<br>SI → <span class=\"diff-highlight\">SHI</span> / TI → <span class=\"diff-highlight\">CHI</span><br>TU → <span class=\"diff-highlight\">TSU</span> / HU → <span class=\"diff-highlight\">FU</span><br>👉 <strong>パスポートや駅名・道路標識</strong>などで一般的。",
                    diff_kunrei_title: "<i class=\"fa-solid fa-school\"></i> 訓令式",
                    diff_kunrei_desc: "五十音表の規則をそのまま表す書き方。<br>さ行: SA <span class=\"kunrei-highlight\">SI</span> SU SE SO<br>た行: TA <span class=\"kunrei-highlight\">TI</span> <span class=\"kunrei-highlight\">TU</span> TE TO<br>👉 <strong>学校のローマ字教育</strong>で習うことが多い。",
                    diff_note_title: "⚠️ 特に間違えやすい注意点",
                    diff_note_1: "<strong>「ふ」</strong> <span class=\"diff-highlight\">ヘボン式: <b>FU</b></span> / <span class=\"kunrei-highlight\">訓令式: <b>HU</b></span><br><strong>「し」</strong> <span class=\"diff-highlight\">ヘボン式: <b>SHI</b></span> / <span class=\"kunrei-highlight\">訓令式: <b>SI</b></span><br><strong>「ち」</strong> <span class=\"diff-highlight\">ヘボン式: <b>CHI</b></span> / <span class=\"kunrei-highlight\">訓令式: <b>TI</b></span><br><strong>「つ」</strong> <span class=\"diff-highlight\">ヘボン式: <b>TSU</b></span> / <span class=\"kunrei-highlight\">訓令式: <b>TU</b></span>",
                    diff_note_2: "<strong>「しゃ・しゅ・しょ」</strong><br><span class=\"diff-highlight\">ヘボン式: <b>SHA・SHU・SHO</b></span><br><span class=\"kunrei-highlight\">訓令式: <b>SYA・SYU・SYO</b></span><br><br><strong>「ちゃ・ちゅ・ちょ」</strong><br><span class=\"diff-highlight\">ヘボン式: <b>CHA・CHU・CHO</b></span><br><span class=\"kunrei-highlight\">訓令式: <b>TYA・TYU・TYO</b></span><br><br><strong>「じゃ・じゅ・じょ」</strong><br><span class=\"diff-highlight\">ヘボン式: <b>JA・JU・JO</b></span><br><span class=\"kunrei-highlight\">訓令式: <b>ZYA・ZYU・ZYO</b></span>"
                },
                numbers: { title_main: { left: "左手用 数字・漢数字 練習帳", right: "右手用 数字・漢数字 練習帳" }, step1_title: "算用数字 (0〜9)", step2_title: "漢数字 (一〜万、〇、円)" }
            },
            en: {
                common: {
                    btn_clear_page: "Clear", btn_clear_all: "Clear All", btn_save_page: "Save", btn_save_all: "Save All", btn_prev: "Prev", btn_next: "Next",
                    loading_title: "Saving...", loading_desc: "Saving all pages may take a while.<br>If asked 'Allow downloading multiple files?', please select <b>Allow</b>.",
                    script_hiragana: "Hiragana", script_katakana: "Katakana", script_alphabet: "Romaji", script_numbers: "Numbers", hand_left: "Left", hand_right: "Right",
                    kana: "Kana", hebon: "Hepburn", kunrei: "Kunrei",
                    modal_guide: "Long press the image to save it to your Photos."
                },
                hiragana: { title_main: { left: "Left-Handed Hiragana", right: "Right-Handed Hiragana" }, title_aiueo_v: "Vertical Gojuon", title_aiueo_h: "Horizontal Gojuon", step1_title: "1-Stroke Lines", step2_title: "Simple Curves", step3_title: "Fold Back & Wave", step4_title: "Intersections", step5_title: "Looping 'Knots'" },
                katakana: { title_main: { left: "Left-Handed Katakana", right: "Right-Handed Katakana" }, title_aiueo_v: "Vertical Gojuon", title_aiueo_h: "Horizontal Gojuon", step1_title: "Simple Lines", step2_title: "Direction & Angle", step3_title: "Folds & Corners", step4_title: "Intersections", step5_title: "Spacing" },
                alphabet: {
                    title_main: { left: "Left-Handed Alphabet & Romaji", right: "Right-Handed Alphabet & Romaji" },
                    step1_title: "Alphabet Uppercase (A-Z)", step2_title: "Alphabet Lowercase (a-z)", step3_title: "Romaji (A - No)", step4_title: "Romaji (Ha - Wa)", step5_title: "Romaji Dakuon (Ga - Pa)", step6_title: "Romaji Yoon 1 (Kya - Nya)", step7_title: "Romaji Yoon 2 (Hya - Rya)", step8_title: "Romaji Yoon 3 (Gya - Pya)",
                    step9_title: "Differences: Hepburn vs Kunrei (Consonants)", step10_title: "Differences: Hepburn vs Kunrei (Yoon)",
                    diff_hebon_title: "<i class=\"fa-solid fa-passport\"></i> Hepburn",
                    diff_hebon_desc: "Pronunciation-based spelling.<br>SI → <span class=\"diff-highlight\">SHI</span> / TI → <span class=\"diff-highlight\">CHI</span><br>TU → <span class=\"diff-highlight\">TSU</span> / HU → <span class=\"diff-highlight\">FU</span><br>👉 Used in <strong>passports and signs</strong>.",
                    diff_kunrei_title: "<i class=\"fa-solid fa-school\"></i> Kunrei",
                    diff_kunrei_desc: "Follows the Gojuon rules.<br>Sa row: SA <span class=\"kunrei-highlight\">SI</span> SU SE SO<br>Ta row: TA <span class=\"kunrei-highlight\">TI</span> <span class=\"kunrei-highlight\">TU</span> TE TO<br>👉 Taught in <strong>Japanese schools</strong>.",
                    diff_note_title: "⚠️ Common Mistakes",
                    diff_note_1: "<strong>「ふ(fu/hu)」</strong> <span class=\"diff-highlight\">Hepburn: <b>FU</b></span> / <span class=\"kunrei-highlight\">Kunrei: <b>HU</b></span><br><strong>「し(shi/si)」</strong> <span class=\"diff-highlight\">Hepburn: <b>SHI</b></span> / <span class=\"kunrei-highlight\">Kunrei: <b>SI</b></span><br><strong>「ち(chi/ti)」</strong> <span class=\"diff-highlight\">Hepburn: <b>CHI</b></span> / <span class=\"kunrei-highlight\">Kunrei: <b>TI</b></span><br><strong>「つ(tsu/tu)」</strong> <span class=\"diff-highlight\">Hepburn: <b>TSU</b></span> / <span class=\"kunrei-highlight\">Kunrei: <b>TU</b></span>",
                    diff_note_2: "<strong>「しゃ・しゅ・しょ」</strong><br><span class=\"diff-highlight\">Hepburn: <b>SHA・SHU・SHO</b></span><br><span class=\"kunrei-highlight\">Kunrei: <b>SYA・SYU・SYO</b></span><br><br><strong>「ちゃ・ちゅ・ちょ」</strong><br><span class=\"diff-highlight\">Hepburn: <b>CHA・CHU・CHO</b></span><br><span class=\"kunrei-highlight\">Kunrei: <b>TYA・TYU・TYO</b></span><br><br><strong>「じゃ・じゅ・じょ」</strong><br><span class=\"diff-highlight\">Hepburn: <b>JA・JU・JO</b></span><br><span class=\"kunrei-highlight\">Kunrei: <b>ZYA・ZYU・ZYO</b></span>"
                },
                numbers: { title_main: { left: "Left-Handed Numbers", right: "Right-Handed Numbers" }, step1_title: "Arabic Numerals (0-9)", step2_title: "Kanji Numerals" }
            }
        };

        const charsData = {
            hiragana: {
                steps: [ 
                    { id: 1, chars: ['し','つ','く','へ'] }, 
                    { id: 2, chars: ['の','て','と','い','こ','り','ひ'] }, 
                    { id: 3, chars: ['う','え','そ','や','ゆ','よ','る','ろ','ん'] }, 
                    { id: 4, chars: ['か','き','け','さ','せ','た','ち','に','ふ','も','ら','わ','を'] }, 
                    { id: 5, chars: ['あ','お','す','な','ぬ','ね','は','ほ','ま','み','む','め','れ'] } 
                ],
                aiueo: [ 
                    ['あ','い','う','え','お'], ['か','き','く','け','こ'], ['さ','し','す','せ','そ'], 
                    ['た','ち','つ','て','と'], ['な','に','ぬ','ね','の'], ['は','ひ','ふ','へ','ほ'], 
                    ['ま','み','む','め','も'], ['や','','ゆ','','よ'], ['ら','り','る','れ','ろ'], 
                    ['わ','','を','','ん'] 
                ]
            },
            katakana: {
                steps: [ 
                    { id: 1, chars: ['イ','ノ','ハ','ヘ','ト','ニ','フ'] }, 
                    { id: 2, chars: ['シ','ツ','ソ','ン','ム','ヤ','ク'] }, 
                    { id: 3, chars: ['ア','カ','コ','サ','セ','タ','テ','ナ','ネ','ラ','ロ'] }, 
                    { id: 4, chars: ['マ','メ','キ','ケ','チ','ヌ','ス','ホ','モ','ヨ','ル','ワ'] }, 
                    { id: 5, chars: ['ウ','エ','オ','ヒ','ミ','ユ','リ','レ','ヲ'] } 
                ],
                aiueo: [ 
                    ['ア','イ','ウ','エ','オ'], ['カ','キ','ク','ケ','コ'], ['サ','シ','ス','セ','ソ'], 
                    ['タ','チ','ツ','テ','ト'], ['ナ','ニ','ヌ','ネ','ノ'], ['ハ','ヒ','フ','ヘ','ホ'], 
                    ['マ','ミ','ム','メ','モ'], ['ヤ','','ユ','','ヨ'], ['ラ','リ','ル','レ','ロ'], 
                    ['ワ','','ヲ','','ン'] 
                ]
            },
            alphabet: {
                uppercase: [ 
                    ['A','B','C','D','E','F','G'], ['H','I','J','K','L','M','N'], 
                    ['O','P','Q','R','S','T','U'], ['V','W','X','Y','Z'] 
                ],
                lowercase: [ 
                    ['a','b','c','d','e','f','g'], ['h','i','j','k','l','m','n'], 
                    ['o','p','q','r','s','t','u'], ['v','w','x','y','z'] 
                ],
                romaji1: [ 
                    [{c:'a',h:'あ',k:'ア'},{c:'i',h:'い',k:'イ'},{c:'u',h:'う',k:'ウ'},{c:'e',h:'え',k:'エ'},{c:'o',h:'お',k:'オ'}], 
                    [{c:'ka',h:'か',k:'カ'},{c:'ki',h:'き',k:'キ'},{c:'ku',h:'く',k:'ク'},{c:'ke',h:'け',k:'ケ'},{c:'ko',h:'こ',k:'コ'}], 
                    [{c:'sa',h:'さ',k:'サ'},{c:'s<span class="diff-highlight">h</span>i',h:'し',k:'シ'},{c:'su',h:'す',k:'ス'},{c:'se',h:'せ',k:'セ'},{c:'so',h:'そ',k:'ソ'}], 
                    [{c:'ta',h:'た',k:'タ'},{c:'<span class="diff-highlight">c</span>hi',h:'ち',k:'チ'},{c:'t<span class="diff-highlight">s</span>u',h:'つ',k:'ツ'},{c:'te',h:'て',k:'テ'},{c:'to',h:'と',k:'ト'}], 
                    [{c:'na',h:'な',k:'ナ'},{c:'ni',h:'に',k:'ニ'},{c:'nu',h:'ぬ',k:'ヌ'},{c:'ne',h:'ね',k:'ネ'},{c:'no',h:'の',k:'ノ'}] 
                ],
                romaji2: [ 
                    [{c:'ha',h:'は',k:'ハ'},{c:'hi',h:'ひ',k:'ヒ'},{c:'<span class="diff-highlight">f</span>u',h:'ふ',k:'フ'},{c:'he',h:'へ',k:'ヘ'},{c:'ho',h:'ほ',k:'ホ'}], 
                    [{c:'ma',h:'ま',k:'マ'},{c:'mi',h:'み',k:'ミ'},{c:'mu',h:'む',k:'ム'},{c:'me',h:'め',k:'メ'},{c:'mo',h:'も',k:'モ'}], 
                    [{c:'ya',h:'や',k:'ヤ'},null,{c:'yu',h:'ゆ',k:'ユ'},null,{c:'yo',h:'よ',k:'ヨ'}], 
                    [{c:'ra',h:'ら',k:'ラ'},{c:'ri',h:'り',k:'リ'},{c:'ru',h:'る',k:'ル'},{c:'re',h:'れ',k:'レ'},{c:'ro',h:'ろ',k:'ロ'}], 
                    [{c:'wa',h:'わ',k:'ワ'},null,{c:'o',h:'を',k:'ヲ'},null,{c:'n',h:'ん',k:'ン'}] 
                ],
                romaji3: [ 
                    [{c:'ga',h:'が',k:'ガ'},{c:'gi',h:'ぎ',k:'ギ'},{c:'gu',h:'ぐ',k:'グ'},{c:'ge',h:'げ',k:'ゲ'},{c:'go',h:'ご',k:'ゴ'}], 
                    [{c:'za',h:'ざ',k:'ザ'},{c:'<span class="diff-highlight">j</span>i',h:'じ',k:'ジ'},{c:'zu',h:'ず',k:'ズ'},{c:'ze',h:'ぜ',k:'ゼ'},{c:'zo',h:'ぞ',k:'ゾ'}], 
                    [{c:'da',h:'だ',k:'ダ'},{c:'<span class="diff-highlight">j</span>i',h:'ぢ',k:'ヂ'},{c:'zu',h:'づ',k:'ヅ'},{c:'de',h:'で',k:'デ'},{c:'do',h:'ど',k:'ド'}], 
                    [{c:'ba',h:'ば',k:'バ'},{c:'bi',h:'び',k:'ビ'},{c:'bu',h:'ぶ',k:'ブ'},{c:'be',h:'べ',k:'ベ'},{c:'bo',h:'ぼ',k:'ボ'}], 
                    [{c:'pa',h:'ぱ',k:'パ'},{c:'pi',h:'ぴ',k:'ピ'},{c:'pu',h:'ぷ',k:'プ'},{c:'pe',h:'ぺ',k:'ペ'},{c:'po',h:'ぽ',k:'ポ'}] 
                ],
                romaji4: [ 
                    [{c:'kya',h:'きゃ',k:'キャ'},{c:'kyu',h:'きゅ',k:'キュ'},{c:'kyo',h:'きょ',k:'キョ'}], 
                    [{c:'s<span class="diff-highlight">h</span>a',h:'しゃ',k:'シャ'},{c:'s<span class="diff-highlight">h</span>u',h:'しゅ',k:'シュ'},{c:'s<span class="diff-highlight">h</span>o',h:'しょ',k:'ショ'}], 
                    [{c:'<span class="diff-highlight">ch</span>a',h:'ちゃ',k:'チャ'},{c:'<span class="diff-highlight">ch</span>u',h:'ちゅ',k:'チュ'},{c:'<span class="diff-highlight">ch</span>o',h:'ちょ',k:'チョ'}], 
                    [{c:'nya',h:'にゃ',k:'ニャ'},{c:'nyu',h:'にゅ',k:'ニュ'},{c:'nyo',h:'にょ',k:'ニョ'}] 
                ],
                romaji5: [ 
                    [{c:'hya',h:'ひゃ',k:'ヒャ'},{c:'hyu',h:'ひゅ',k:'ヒュ'},{c:'hyo',h:'ひょ',k:'ヒョ'}], 
                    [{c:'mya',h:'みゃ',k:'ミャ'},{c:'myu',h:'みゅ',k:'ミュ'},{c:'myo',h:'みょ',k:'ミョ'}], 
                    [{c:'rya',h:'りゃ',k:'リャ'},{c:'ryu',h:'りゅ',k:'リュ'},{c:'ryo',h:'りょ',k:'リョ'}] 
                ],
                romaji6: [ 
                    [{c:'gya',h:'ぎゃ',k:'ギャ'},{c:'gyu',h:'ぎゅ',k:'ギュ'},{c:'gyo',h:'ぎょ',k:'ギョ'}], 
                    [{c:'<span class="diff-highlight">j</span>a',h:'じゃ',k:'ジャ'},{c:'<span class="diff-highlight">j</span>u',h:'じゅ',k:'ジュ'},{c:'<span class="diff-highlight">j</span>o',h:'じょ',k:'ジョ'}], 
                    [{c:'bya',h:'びゃ',k:'ビャ'},{c:'byu',h:'びゅ',k:'ビュ'},{c:'byo',h:'びょ',k:'ビョ'}], 
                    [{c:'pya',h:'ぴゃ',k:'ピャ'},{c:'pyu',h:'ぴゅ',k:'ピュ'},{c:'pyo',h:'ぴょ',k:'ピョ'}] 
                ],
                romajiDiff1: [
                    { h: 'し', hebon: 'S<span class="diff-highlight">H</span>I', kunrei: '<span class="kunrei-highlight">S</span>I' },
                    { h: 'ち', hebon: '<span class="diff-highlight">C</span>HI', kunrei: '<span class="kunrei-highlight">T</span>I' },
                    { h: 'つ', hebon: 'T<span class="diff-highlight">S</span>U', kunrei: '<span class="kunrei-highlight">TU</span>' },
                    { h: 'ふ', hebon: '<span class="diff-highlight">F</span>U', kunrei: '<span class="kunrei-highlight">H</span>U' },
                    { h: 'じ', hebon: '<span class="diff-highlight">J</span>I', kunrei: '<span class="kunrei-highlight">Z</span>I' },
                    { h: 'ぢ', hebon: '<span class="diff-highlight">J</span>I', kunrei: '<span class="kunrei-highlight">Z</span>I' },
                    { h: 'づ', hebon: 'ZU', kunrei: 'ZU' }
                ],
                romajiDiff2_col1: [
                    { h: 'しゃ', hebon: 'S<span class="diff-highlight">H</span>A', kunrei: 'S<span class="kunrei-highlight">Y</span>A' },
                    { h: 'しゅ', hebon: 'S<span class="diff-highlight">H</span>U', kunrei: 'S<span class="kunrei-highlight">Y</span>U' },
                    { h: 'しょ', hebon: 'S<span class="diff-highlight">H</span>O', kunrei: 'S<span class="kunrei-highlight">Y</span>O' },
                    { h: 'じゃ', hebon: '<span class="diff-highlight">J</span>A', kunrei: '<span class="kunrei-highlight">ZY</span>A' },
                    { h: 'じゅ', hebon: '<span class="diff-highlight">J</span>U', kunrei: '<span class="kunrei-highlight">ZY</span>U' },
                    { h: 'じょ', hebon: '<span class="diff-highlight">J</span>O', kunrei: '<span class="kunrei-highlight">ZY</span>O' }
                ],
                romajiDiff2_col2: [
                    { h: 'ちゃ', hebon: '<span class="diff-highlight">CH</span>A', kunrei: '<span class="kunrei-highlight">TY</span>A' },
                    { h: 'ちゅ', hebon: '<span class="diff-highlight">CH</span>U', kunrei: '<span class="kunrei-highlight">TY</span>U' },
                    { h: 'ちょ', hebon: '<span class="diff-highlight">CH</span>O', kunrei: '<span class="kunrei-highlight">TY</span>O' },
                    { h: 'ぢゃ', hebon: '<span class="diff-highlight">J</span>A', kunrei: '<span class="kunrei-highlight">ZY</span>A' },
                    { h: 'ぢゅ', hebon: '<span class="diff-highlight">J</span>U', kunrei: '<span class="kunrei-highlight">ZY</span>U' },
                    { h: 'ぢょ', hebon: '<span class="diff-highlight">J</span>O', kunrei: '<span class="kunrei-highlight">ZY</span>O' }
                ]
            },
            numbers: { 
                arabic: ['1','2','3','4','5','6','7','8','9','0'], 
                kanji: [ 
                    [{c:'一',r:'いち',n:'1'},{c:'二',r:'に',n:'2'},{c:'三',r:'さん',n:'3'}], 
                    [{c:'四',r:'よん',n:'4'},{c:'五',r:'ご',n:'5'},{c:'六',r:'ろく',n:'6'}], 
                    [{c:'七',r:'なな',n:'7'},{c:'八',r:'はち',n:'8'},{c:'九',r:'きゅう',n:'9'}], 
                    [{c:'十',r:'じゅう',n:'10'},{c:'百',r:'ひゃく',n:'100'},{c:'千',r:'せん',n:'1000'}], 
                    [{c:'万',r:'まん',n:'10000'},{c:'〇',r:'ぜろ',n:'0'},{c:'円',r:'えん',n:'¥'}] 
                ] 
            }
        };

        window.onload = () => { 
            renderPages(); 
            applyLanguage(); 
            updatePageDisplay(); 
            initDrawing(); 
            updateNavigation(); 
            updateCursor(); 
            initPanAndZoom(); 
        };

        function stopSpeaking() { 
            if ('speechSynthesis' in window) speechSynthesis.cancel(); 
        }
        
        function speakText(text, forceLang = null) {
            if (!('speechSynthesis' in window)) return;
            // 記号や特定の単語を自然に読めるように置き換え(「帳」問題などの回避)
            let cleanText = text.replace(/[※\*]/g, '')
                                .replace(/×/g, '')
                                .replace(/→/g, '、') // 「つぎに」ではなく読点にして一呼吸置く
                                .replace(/〜/g, 'から')
                                .replace(/行/g, 'ぎょう')
                                .replace(/半濁音/g, 'はんだくおん')
                                .replace(/濁音/g, 'だくおん')
                                .replace(/拗音/g, 'ようおん')
                                .replace(/英語話者/g, 'えいごわしゃ')
                                .replace(/練習帳/g, 'れんしゅうちょう')
                                .replace(/帳/g, 'ちょう')
                                .replace(/\//g, '、') 
                                .replace(/:/g, 'は、') 
                                .replace(/👉/g, ''); 

            // ページ全体の説明文などで、アルファベットの連続を単語読みではなく1文字ずつ(スペル)読ませる
            cleanText = cleanText.replace(/[A-Z]{2,}/g, match => {
                return match.split('').join(', ').toLowerCase();
            });
                                
            const msg = new SpeechSynthesisUtterance(cleanText);
            msg.lang = forceLang ? forceLang : (currentLang === 'en' ? 'en-US' : 'ja-JP');
            msg.rate = 0.85; 
            speechSynthesis.speak(msg);
        }

        function speakChar(char, forceLang = null) {
            const now = Date.now(); 
            if (char === lastSpokenChar && (now - lastSpokenTime) < 1500) return; 
            
            lastSpokenChar = char; 
            lastSpokenTime = now; 
            stopSpeaking();
            
            let textToSpeak = char;
            
            // 日本語の助詞の読み方の調整
            if (char === 'は') textToSpeak = 'ハ'; 
            if (char === 'へ') textToSpeak = 'ヘ';

            // アルファベット1文字のタップ時に「キャピタル〜」と読まれるのを回避するため、小文字にしてから渡す
            if (forceLang === 'en-US' && textToSpeak.length === 1 && /[A-Z]/.test(textToSpeak)) {
                textToSpeak = textToSpeak.toLowerCase();
            }
            
            speakText(textToSpeak, forceLang || 'ja-JP');
        }

        function speakCurrentPage() { 
            const page = document.querySelector('.page.active'); 
            if (page) speakPageInfo(page); 
        }
        
        function speakPageInfo(page) {
            if (!page) return; 
            stopSpeaking(); 
            
            let speechQueue = []; 
            const mainLang = currentLang === 'en' ? 'en-US' : 'ja-JP';
            
            const mainTitle = page.querySelector('.main-title');
            if (mainTitle) {
                const badge = mainTitle.querySelector('.step-badge'); 
                const textSpan = mainTitle.querySelector('span[data-i18n]');
                if (badge) speechQueue.push({ text: badge.innerText, lang: mainLang });
                if (textSpan) speechQueue.push({ text: textSpan.innerText, lang: mainLang });
            }
            
            if (currentScript === 'alphabet' && currentPage >= 9) {
                // Step 9, 10 用の説明文と文字リストの読み上げ
                const diffCards = page.querySelectorAll('.diff-card');
                diffCards.forEach(card => {
                    const h4 = card.querySelector('h4');
                    const p = card.querySelector('p');
                    if (h4) speechQueue.push({ text: h4.innerText, lang: mainLang });
                    if (p) speechQueue.push({ text: p.innerText, lang: mainLang });
                });

                const prefix = currentLang === 'en' ? "Characters are: " : "このページの文字は、"; 
                speechQueue.push({ text: prefix, lang: mainLang });
                let chars = [];
                page.querySelectorAll('.diff-row .diff-col-kana').forEach(cell => {
                    const char = cell.innerText.trim();
                    if(char && char !== 'かな' && char !== 'Kana' && !chars.includes(char)) {
                        chars.push(char);
                    }
                });
                speechQueue.push({ text: chars.join('、'), lang: 'ja-JP' });

            } else if (currentScript === 'alphabet') {
                const prefix = currentLang === 'en' ? "Characters are: " : "このページの文字は、"; 
                speechQueue.push({ text: prefix, lang: mainLang });
                let chars = [];
                page.querySelectorAll('.char-block[data-char]').forEach(block => {
                    const char = block.getAttribute('data-char');
                    if(char && !chars.includes(char)) chars.push(char);
                });
                
                if (currentPage === 1 || currentPage === 2) {
                    // アルファベット単体の誤読を防ぐため、カンマとスペースで完全に区切る
                    // 大文字のページ(currentPage === 1)の場合も「キャピタル」回避のため小文字にする
                    if (currentPage === 1) {
                        chars = chars.map(c => c.toLowerCase());
                    }
                    speechQueue.push({ text: chars.join(', '), lang: 'en-US' });
                } else {
                    speechQueue.push({ text: chars.join('、'), lang: 'ja-JP' });
                }
                
            } else if ((currentScript === 'hiragana' || currentScript === 'katakana') && (currentPage === 7 || currentPage === 8)) {
                const prefix = currentLang === 'en' ? "Characters are: " : "このページの文字は、"; 
                speechQueue.push({ text: prefix, lang: mainLang });
                let chars = []; 
                charsData[currentScript].aiueo.forEach(group => { 
                    group.forEach(char => { if (char !== '') chars.push(char); }); 
                });
                speechQueue.push({ text: chars.join('、'), lang: 'ja-JP' });

            } else if (currentPage >= 2 && currentScript !== 'numbers') {
                const charBlocks = page.querySelectorAll('.char-block[data-char]');
                if (charBlocks.length > 0) {
                    const prefix = currentLang === 'en' ? "Characters are: " : "このページの文字は、"; 
                    speechQueue.push({ text: prefix, lang: mainLang });
                    let chars = []; 
                    charBlocks.forEach(block => { 
                        const char = block.getAttribute('data-ruby') || block.getAttribute('data-char'); 
                        if(char) chars.push(char); 
                    });
                    speechQueue.push({ text: chars.join('、'), lang: 'ja-JP' });
                }
            } else if (currentPage === 1 && currentScript !== 'numbers') {
                const stepTitles = page.querySelectorAll('.step-title-small'); 
                const containers = page.querySelectorAll('.blocks-container');
                stepTitles.forEach((stepTitle, idx) => {
                    const badge = stepTitle.querySelector('.step-badge'); 
                    const textSpan = stepTitle.querySelector('span[data-i18n]');
                    if (badge) speechQueue.push({ text: badge.innerText, lang: mainLang });
                    if (textSpan) speechQueue.push({ text: textSpan.innerText, lang: mainLang });
                    
                    const container = containers[idx];
                    if (container) {
                        let chars = []; 
                        container.querySelectorAll('.char-block[data-char]').forEach(block => { 
                            const char = block.getAttribute('data-char'); 
                            if(char) chars.push(char); 
                        });
                        if (chars.length > 0) speechQueue.push({ text: chars.join('、'), lang: 'ja-JP' });
                    }
                });
            }

            speechQueue.forEach(item => { 
                const punctuation = item.lang === 'en-US' ? '.' : '。'; 
                let textToSpeak = item.text; 
                if (!textToSpeak.endsWith(punctuation)) textToSpeak += punctuation; 
                speakText(textToSpeak, item.lang); 
            });
        }

        function getTotalPages() { 
            if (currentScript === 'numbers') return 2; 
            if (currentScript === 'alphabet') return 10; 
            return 8; 
        }

        function setScriptMode(mode) {
            if (mode === 'kana') { 
                if (currentScript === 'hiragana' || currentScript === 'katakana') { 
                    currentScript = currentScript === 'hiragana' ? 'katakana' : 'hiragana'; 
                    lastKana = currentScript; 
                } else { 
                    currentScript = lastKana; 
                    currentPage = lastKanaPage; 
                } 
            } 
            else if (mode === 'alphabet') { 
                if (currentScript !== 'alphabet') { 
                    if (currentScript === 'hiragana' || currentScript === 'katakana') lastKanaPage = currentPage; 
                    currentScript = 'alphabet'; 
                    currentPage = 1; 
                } 
            } 
            else if (mode === 'numbers') { 
                if (currentScript !== 'numbers') { 
                    if (currentScript === 'hiragana' || currentScript === 'katakana') lastKanaPage = currentPage; 
                    currentScript = 'numbers'; 
                    currentPage = 1; 
                } 
            }
            updatePageDisplay(); 
            applyLanguage();
        }

        function toggleHand() { 
            currentHand = currentHand === 'left' ? 'right' : 'left'; 
            document.body.classList.toggle('left-hand', currentHand === 'left'); 
            document.body.classList.toggle('right-hand', currentHand === 'right'); 
            applyLanguage(); 
        }

        function toggleLang() { 
            currentLang = currentLang === 'jp' ? 'en' : 'jp'; 
            applyLanguage(); 
        }

        function applyLanguage() {
            document.querySelectorAll('[data-i18n]').forEach(el => {
                const keys = el.getAttribute('data-i18n').split('.'); 
                let val = i18n[currentLang];
                for (const k of keys) { if (val) val = val[k]; }
                if (val) { 
                    if (typeof val === 'object') el.innerHTML = val[currentHand] || val; 
                    else el.innerHTML = val; 
                }
            });
            
            const handBtn = document.getElementById('hand-toggle-btn');
            if (handBtn) { 
                const text = i18n[currentLang].common[`hand_${currentHand}`]; 
                if (currentHand === 'left') { 
                    handBtn.innerHTML = `<i class="fa-solid fa-hand"></i> <span>${text}</span>`; 
                } else { 
                    handBtn.innerHTML = `<span>${text}</span> <i class="fa-solid fa-hand"></i>`; 
                } 
            }
            
            const kanaBtnText = document.getElementById('kana-btn-text');
            if (kanaBtnText) { 
                const targetScript = (currentScript === 'hiragana' || currentScript === 'katakana') ? currentScript : lastKana; 
                kanaBtnText.innerText = i18n[currentLang].common[`script_${targetScript}`]; 
            }
            
            document.getElementById('jump-v-text').innerText = currentScript === 'katakana' ? 'ア' : 'あ'; 
            document.getElementById('jump-h-text').innerText = currentScript === 'katakana' ? 'ア' : 'あ';
        }

        function renderPages() {
            const container = document.getElementById('pages-container'); 
            container.innerHTML = ''; 
            
            // --- ひらがな・カタカナ ---
            ['hiragana', 'katakana'].forEach(script => {
                const sData = charsData[script].steps; 
                const aData = charsData[script].aiueo;
                
                let page1 = `<div id="page-${script}-1" class="page"><h1 class="main-title"><span data-i18n="${script}.title_main"></span></h1><div class="summary-wrapper">`;
                sData.forEach((data, i) => {
                    let mt = i > 0 ? 'margin-top: 18px;' : 'margin-top: 16px;';
                    page1 += `<div class="step-title-small" style="${mt}"><span class="step-badge">Step ${i+1}</span> <span data-i18n="${script}.step${i+1}_title"></span></div><div class="blocks-container compact">`;
                    data.chars.forEach(char => { 
                        page1 += `<div class="char-block compact" data-char="${char}">
                            <div class="cell compact sample" onclick="speakChar('${char}')" title="読み上げる"><span class="main-char">${char}</span></div>
                            <div class="cell compact trace"><span class="main-char">${char}</span><canvas data-size="43"></canvas></div>
                            <div class="cell compact"><canvas data-size="43"></canvas></div>
                        </div>`; 
                    });
                    page1 += `</div>`;
                });
                page1 += `</div></div>`; 
                container.insertAdjacentHTML('beforeend', page1);

                sData.forEach((data, index) => {
                    let pageHtml = `<div id="page-${script}-${index+2}" class="page"><h1 class="main-title"><span class="step-badge large">Step ${index+1}</span> <span data-i18n="${script}.step${index+1}_title"></span></h1><div class="blocks-container step-layout">`;
                    data.chars.forEach(char => { 
                        pageHtml += `<div class="char-block step" data-char="${char}">
                            <div class="cell step sample" onclick="speakChar('${char}')" title="読み上げる"><span class="main-char">${char}</span></div>
                            <div class="cell step trace"><span class="main-char">${char}</span><canvas data-size="105"></canvas></div>
                            <div class="cell step"><canvas data-size="105"></canvas></div>
                        </div>`; 
                    });
                    pageHtml += `</div></div>`; 
                    container.insertAdjacentHTML('beforeend', pageHtml);
                });

                let page7 = `<div id="page-${script}-7" class="page"><h1 class="main-title"><span data-i18n="${script}.title_aiueo_v"></span></h1><div class="vertical-layout">`; 
                for(let i=0; i<5; i++){ 
                    page7 += `<div class="column-group">`; 
                    aData[i].forEach(char => { 
                        if(char === '') {
                            page7 += `<div class="char-block aiueo" style="visibility: hidden;"><div class="cell aiueo"></div><div class="cell aiueo"></div><div class="cell aiueo"></div></div>`; 
                        } else {
                            page7 += `<div class="char-block aiueo" data-char="${char}">
                                <div class="cell aiueo sample" onclick="speakChar('${char}')" title="読み上げる"><span class="main-char">${char}</span></div>
                                <div class="cell aiueo trace"><span class="main-char">${char}</span><canvas data-size="54"></canvas></div>
                                <div class="cell aiueo"><canvas data-size="54"></canvas></div>
                            </div>`; 
                        }
                    }); 
                    page7 += `</div>`; 
                }
                page7 += `</div><div class="vertical-layout" style="margin-top: 40px;">`; 
                for(let i=5; i<10; i++){ 
                    page7 += `<div class="column-group">`; 
                    aData[i].forEach(char => { 
                        if(char === '') {
                            page7 += `<div class="char-block aiueo" style="visibility: hidden;"><div class="cell aiueo"></div><div class="cell aiueo"></div><div class="cell aiueo"></div></div>`; 
                        } else {
                            page7 += `<div class="char-block aiueo" data-char="${char}">
                                <div class="cell aiueo sample" onclick="speakChar('${char}')" title="読み上げる"><span class="main-char">${char}</span></div>
                                <div class="cell aiueo trace"><span class="main-char">${char}</span><canvas data-size="54"></canvas></div>
                                <div class="cell aiueo"><canvas data-size="54"></canvas></div>
                            </div>`; 
                        }
                    }); 
                    page7 += `</div>`; 
                }
                page7 += `</div></div>`; 
                container.insertAdjacentHTML('beforeend', page7);

                let page8 = `<div id="page-${script}-8" class="page"><h1 class="main-title"><span data-i18n="${script}.title_aiueo_h"></span></h1><div class="horizontal-layout">`;
                aData.forEach((group, idx) => { 
                    let mt = (idx === 5) ? 'margin-top: 24px;' : ''; 
                    page8 += `<div class="row-group" style="${mt}">`; 
                    group.forEach(char => { 
                        if(char === '') {
                            page8 += `<div class="char-block aiueo" style="visibility: hidden;"><div class="cell aiueo"></div><div class="cell aiueo"></div><div class="cell aiueo"></div></div>`; 
                        } else {
                            page8 += `<div class="char-block aiueo" data-char="${char}">
                                <div class="cell aiueo sample" onclick="speakChar('${char}')" title="読み上げる"><span class="main-char">${char}</span></div>
                                <div class="cell aiueo trace"><span class="main-char">${char}</span><canvas data-size="54"></canvas></div>
                                <div class="cell aiueo"><canvas data-size="54"></canvas></div>
                            </div>`; 
                        }
                    }); 
                    page8 += `</div>`; 
                });
                page8 += `</div></div>`; 
                container.insertAdjacentHTML('beforeend', page8);
            });

            // --- アルファベット ---
            let pageAlphabet1 = `<div id="page-alphabet-1" class="page"><h1 class="main-title"><span class="step-badge large">Step 1</span> <span data-i18n="alphabet.step1_title"></span></h1><div class="blocks-container alphabet-layout">`;
            charsData.alphabet.uppercase.forEach(row => { 
                pageAlphabet1 += `<div class="row-group">`; 
                row.forEach(char => { 
                    pageAlphabet1 += `<div class="char-block alphabet" data-char="${char}">
                        <div class="cell alphabet sample" onclick="speakChar('${char}', 'en-US')" title="読み上げる"><span class="main-char">${char}</span></div>
                        <div class="cell alphabet trace"><span class="main-char">${char}</span><canvas data-size="44"></canvas></div>
                        <div class="cell alphabet"><canvas data-size="44"></canvas></div>
                    </div>`; 
                }); 
                pageAlphabet1 += `</div>`; 
            });
            pageAlphabet1 += `</div></div>`; 
            container.insertAdjacentHTML('beforeend', pageAlphabet1);

            let pageAlphabet2 = `<div id="page-alphabet-2" class="page"><h1 class="main-title"><span class="step-badge large">Step 2</span> <span data-i18n="alphabet.step2_title"></span></h1><div class="blocks-container alphabet-layout">`;
            charsData.alphabet.lowercase.forEach(row => { 
                pageAlphabet2 += `<div class="row-group">`; 
                row.forEach(char => { 
                    pageAlphabet2 += `<div class="char-block alphabet" data-char="${char}">
                        <div class="cell alphabet sample" onclick="speakChar('${char}', 'en-US')" title="読み上げる"><span class="main-char">${char}</span></div>
                        <div class="cell alphabet trace"><span class="main-char">${char}</span><canvas data-size="44"></canvas></div>
                        <div class="cell alphabet"><canvas data-size="44"></canvas></div>
                    </div>`; 
                }); 
                pageAlphabet2 += `</div>`; 
            });
            pageAlphabet2 += `</div></div>`; 
            container.insertAdjacentHTML('beforeend', pageAlphabet2);

            const romajiPages = [ 
                { id: 3, data: charsData.alphabet.romaji1 }, { id: 4, data: charsData.alphabet.romaji2 }, 
                { id: 5, data: charsData.alphabet.romaji3 }, { id: 6, data: charsData.alphabet.romaji4 }, 
                { id: 7, data: charsData.alphabet.romaji5 }, { id: 8, data: charsData.alphabet.romaji6 } 
            ];
            
            romajiPages.forEach(pageInfo => {
                const isYoon = (pageInfo.id >= 6); 
                const blockClass = isYoon ? "char-block romaji-yoon" : "char-block romaji"; 
                const cellClass = isYoon ? "cell romaji-yoon" : "cell romaji"; 
                const canvasSize = isYoon ? 96 : 64;
                
                let html = `<div id="page-alphabet-${pageInfo.id}" class="page"><h1 class="main-title"><span class="step-badge large">Step ${pageInfo.id}</span> <span data-i18n="alphabet.step${pageInfo.id}_title"></span></h1><div class="blocks-container alphabet-layout">`; 
                pageInfo.data.forEach(row => {
                    let rowStyle = ""; 
                    if (!isYoon && row.length === 3) rowStyle = 'style="gap: 50px;"';
                    
                    html += `<div class="row-group" ${rowStyle}>`;
                    row.forEach(item => {
                        if (!item) {
                            html += `<div class="${blockClass}" style="visibility: hidden;"><div class="${cellClass}"></div><div class="${cellClass}"></div><div class="${cellClass}"></div></div>`;
                        } else {
                            let rawC = item.c.replace(/<[^>]*>?/gm, ''); // なぞり書き用はタグを除外した文字列
                            html += `<div class="${blockClass}" data-char="${item.h}">
                                <div class="${cellClass} sample" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる">
                                    <div class="romaji-kana"><span>${item.h}</span><span>${item.k}</span></div>
                                    <span class="main-char">${item.c}</span>
                                </div>
                                <div class="${cellClass} trace"><span class="main-char">${rawC}</span><canvas data-size="${canvasSize}"></canvas></div>
                                <div class="${cellClass}"><canvas data-size="${canvasSize}"></canvas></div>
                            </div>`;
                        }
                    }); 
                    html += `</div>`;
                }); 
                html += `</div></div>`; 
                container.insertAdjacentHTML('beforeend', html);
            });

            // --- Step 9 (ヘボン式 vs 訓令式:子音) ---
            let pageAlphabet9 = `<div id="page-alphabet-9" class="page">
                <h1 class="main-title"><span class="step-badge large">Step 9</span> <span data-i18n="alphabet.step9_title"></span></h1>
                <div class="diff-page-container">
                    <div class="diff-text-area">
                        <div class="diff-card hebon-card"><h4 data-i18n="alphabet.diff_hebon_title"></h4><p data-i18n="alphabet.diff_hebon_desc"></p></div>
                        <div class="diff-card kunrei-card"><h4 data-i18n="alphabet.diff_kunrei_title"></h4><p data-i18n="alphabet.diff_kunrei_desc"></p></div>
                        <div class="diff-card note-card"><h4 data-i18n="alphabet.diff_note_title"></h4><p data-i18n="alphabet.diff_note_1"></p></div>
                    </div>
                    <div class="diff-practice-area">
                        <div class="diff-table-header"><div class="diff-col-kana" data-i18n="common.kana">かな</div><div class="diff-col-hebon" data-i18n="common.hebon">ヘボン式</div><div class="diff-col-kunrei" data-i18n="common.kunrei">訓令式</div></div>`;
            
            charsData.alphabet.romajiDiff1.forEach(item => {
                let rawHebon = item.hebon.replace(/<[^>]*>?/gm, ''); 
                let rawKunrei = item.kunrei.replace(/<[^>]*>?/gm, '');

                pageAlphabet9 += `<div class="diff-row"><div class="diff-col-kana">${item.h}</div>
                    <div class="diff-col-hebon"><div class="char-block romaji diff-block" data-char="${item.h}">
                        <div class="cell romaji sample diff-cell" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる"><span class="main-char">${item.hebon}</span></div>
                        <div class="cell romaji trace diff-cell"><span class="main-char">${rawHebon}</span><canvas data-size="52"></canvas></div><div class="cell romaji diff-cell"><canvas data-size="52"></canvas></div></div></div>
                    <div class="diff-col-kunrei"><div class="char-block romaji diff-block" data-char="${item.h}">
                        <div class="cell romaji sample diff-cell" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる"><span class="main-char">${item.kunrei}</span></div>
                        <div class="cell romaji trace diff-cell"><span class="main-char">${rawKunrei}</span><canvas data-size="52"></canvas></div><div class="cell romaji diff-cell"><canvas data-size="52"></canvas></div></div></div></div>`;
            });
            pageAlphabet9 += `</div></div></div>`; 
            container.insertAdjacentHTML('beforeend', pageAlphabet9);

            // --- Step 10 (ヘボン式 vs 訓令式:拗音) ---
            let pageAlphabet10 = `<div id="page-alphabet-10" class="page">
                <h1 class="main-title"><span class="step-badge large">Step 10</span> <span data-i18n="alphabet.step10_title"></span></h1>
                <div class="diff-page-container">
                    <div class="diff-text-area" style="width: 330px;"><div class="diff-card note-card" style="height: 100%;"><h4 data-i18n="alphabet.diff_note_title"></h4><p data-i18n="alphabet.diff_note_2" style="margin-top: 10px;"></p></div></div>
                    <div class="diff-practice-area" style="display: flex; gap: 15px; padding: 12px 14px;">
                        <div style="flex: 1;"><div class="diff-table-header small"><div class="diff-col-kana" data-i18n="common.kana">かな</div><div class="diff-col-hebon" data-i18n="common.hebon">ヘボン式</div><div class="diff-col-kunrei" data-i18n="common.kunrei">訓令式</div></div>`;
            
            charsData.alphabet.romajiDiff2_col1.forEach(item => {
                let rawHebon = item.hebon.replace(/<[^>]*>?/gm, ''); 
                let rawKunrei = item.kunrei.replace(/<[^>]*>?/gm, '');

                pageAlphabet10 += `<div class="diff-row"><div class="diff-col-kana">${item.h}</div>
                    <div class="diff-col-hebon"><div class="char-block romaji diff-block" data-char="${item.h}">
                        <div class="cell romaji sample diff-cell small" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる"><span class="main-char">${item.hebon}</span></div>
                        <div class="cell romaji trace diff-cell small"><span class="main-char">${rawHebon}</span><canvas data-size="46"></canvas></div><div class="cell romaji diff-cell small"><canvas data-size="46"></canvas></div></div></div>
                    <div class="diff-col-kunrei"><div class="char-block romaji diff-block" data-char="${item.h}">
                        <div class="cell romaji sample diff-cell small" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる"><span class="main-char">${item.kunrei}</span></div>
                        <div class="cell romaji trace diff-cell small"><span class="main-char">${rawKunrei}</span><canvas data-size="46"></canvas></div><div class="cell romaji diff-cell small"><canvas data-size="46"></canvas></div></div></div></div>`;
            });
            pageAlphabet10 += `</div><div style="flex: 1;"><div class="diff-table-header small"><div class="diff-col-kana" data-i18n="common.kana">かな</div><div class="diff-col-hebon" data-i18n="common.hebon">ヘボン式</div><div class="diff-col-kunrei" data-i18n="common.kunrei">訓令式</div></div>`;
            
            charsData.alphabet.romajiDiff2_col2.forEach(item => {
                let rawHebon = item.hebon.replace(/<[^>]*>?/gm, ''); 
                let rawKunrei = item.kunrei.replace(/<[^>]*>?/gm, '');

                pageAlphabet10 += `<div class="diff-row"><div class="diff-col-kana">${item.h}</div>
                    <div class="diff-col-hebon"><div class="char-block romaji diff-block" data-char="${item.h}">
                        <div class="cell romaji sample diff-cell small" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる"><span class="main-char">${item.hebon}</span></div>
                        <div class="cell romaji trace diff-cell small"><span class="main-char">${rawHebon}</span><canvas data-size="46"></canvas></div><div class="cell romaji diff-cell small"><canvas data-size="46"></canvas></div></div></div>
                    <div class="diff-col-kunrei"><div class="char-block romaji diff-block" data-char="${item.h}">
                        <div class="cell romaji sample diff-cell small" onclick="speakChar('${item.h}', 'ja-JP')" title="読み上げる"><span class="main-char">${item.kunrei}</span></div>
                        <div class="cell romaji trace diff-cell small"><span class="main-char">${rawKunrei}</span><canvas data-size="46"></canvas></div><div class="cell romaji diff-cell small"><canvas data-size="46"></canvas></div></div></div></div>`;
            });
            pageAlphabet10 += `</div></div></div></div>`; 
            container.insertAdjacentHTML('beforeend', pageAlphabet10);

            // --- 数字・漢数字 ---
            let pageNum1 = `<div id="page-numbers-1" class="page"><h1 class="main-title"><span class="step-badge large">Step 1</span> <span data-i18n="numbers.step1_title"></span></h1><div class="blocks-container numbers-layout">`;
            charsData.numbers.arabic.forEach(num => { 
                pageNum1 += `<div class="char-block numbers-block" data-char="${num}">
                    <div class="cell numbers-cell sample" onclick="speakChar('${num}')" title="読み上げる"><span class="main-char">${num}</span></div>
                    <div class="cell numbers-cell trace"><span class="main-char">${num}</span><canvas data-size="105"></canvas></div>
                    <div class="cell numbers-cell"><canvas data-size="105"></canvas></div>
                </div>`; 
            });
            pageNum1 += `</div></div>`; 
            container.insertAdjacentHTML('beforeend', pageNum1);

            let pageNum2 = `<div id="page-numbers-2" class="page"><h1 class="main-title"><span class="step-badge large">Step 2</span> <span data-i18n="numbers.step2_title"></span></h1><div class="blocks-container kanji-layout">`;
            charsData.numbers.kanji.forEach(row => { 
                row.forEach(item => { 
                    pageNum2 += `<div class="char-block kanji" data-char="${item.c}" data-ruby="${item.r}">
                        <div class="cell kanji sample" onclick="speakChar('${item.r}', 'ja-JP')" title="読み上げる"><div class="kanji-ruby">${item.r}</div><span class="main-char">${item.c}</span><div class="kanji-num">${item.n}</div></div>
                        <div class="cell kanji trace"><span class="main-char">${item.c}</span><canvas data-size="105"></canvas></div>
                        <div class="cell kanji"><canvas data-size="105"></canvas></div>
                    </div>`; 
                }); 
            });
            pageNum2 += `</div></div>`; 
            container.insertAdjacentHTML('beforeend', pageNum2);
        }

        function updatePageDisplay() { 
            document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); 
            document.getElementById(`page-${currentScript}-${currentPage}`).classList.add('active'); 
            updateNavigation(); 
            applyZoom(); 
        }

        // ズーム操作関数群
        function changeZoom(delta) { 
            currentZoom += delta; 
            if(currentZoom < 0.5) currentZoom = 0.5; 
            if(currentZoom > 2.5) currentZoom = 2.5; 
            applyZoom(); 
        }
        function resetZoom() { 
            currentZoom = 1.0; 
            applyZoom(); 
        }
        function applyZoom() { 
            const container = document.getElementById('pages-container'); 
            container.style.transform = `scale(${currentZoom})`; 
            const activePage = document.querySelector('.page.active'); 
            if(activePage) { 
                document.body.style.minHeight = `${(activePage.offsetHeight * currentZoom) + 180}px`; 
                document.body.style.minWidth = `${activePage.offsetWidth * currentZoom}px`; 
            } 
        }

        function changePage(delta) { 
            const total = getTotalPages(); 
            currentPage += delta; 
            if (currentPage < 1) currentPage = 1; 
            if (currentPage > total) currentPage = total; 
            updatePageDisplay(); 
        }

        function updateNavigation() { 
            const total = getTotalPages(); 
            const isKana = (currentScript === 'hiragana' || currentScript === 'katakana'); 
            document.getElementById('page-indicator').textContent = `${currentPage} / ${total}`; 
            document.getElementById('prev-btn').disabled = currentPage === 1; 
            document.getElementById('next-btn').disabled = currentPage === total; 
            document.querySelectorAll('.jump-btn').forEach(btn => { btn.style.display = isKana ? 'flex' : 'none'; }); 
        }
        
        function goToTop() { changePage(1 - currentPage); } 
        function jumpToAiueoV() { changePage(7 - currentPage); } 
        function jumpToAiueoH() { changePage(8 - currentPage); } 

        function toggleTheme() { 
            document.body.classList.toggle('dark-mode'); 
            const isDark = document.body.classList.contains('dark-mode');
            const icon = document.getElementById('theme-icon'); 
            icon.className = isDark ? 'fa-solid fa-sun' : 'fa-solid fa-moon'; 
            
            // すでに描かれたキャンバスの線の色を反転させる処理(黒⇔白)
            document.querySelectorAll('canvas').forEach(canvas => {
                const w = canvas.width; 
                const h = canvas.height;
                if(w === 0 || h === 0) return;
                
                const ctx = canvas.getContext('2d');
                const imgData = ctx.getImageData(0, 0, w, h);
                const data = imgData.data;
                for (let i = 0; i < data.length; i += 4) {
                    if (data[i+3] > 0) { // 透明でない描画ピクセルを反転
                        data[i] = 255 - data[i];
                        data[i+1] = 255 - data[i+1];
                        data[i+2] = 255 - data[i+2];
                    }
                }
                ctx.putImageData(imgData, 0, 0);
            });

            updateCursor(); 
        }

        function setTool(tool) { 
            currentTool = tool; 
            document.getElementById('btn-pen').classList.toggle('active', tool === 'pen'); 
            document.getElementById('btn-eraser').classList.toggle('active', tool === 'eraser'); 
            document.getElementById('btn-move').classList.toggle('active', tool === 'move'); 
            
            const canvases = document.querySelectorAll('canvas'); 
            if (tool === 'move') { 
                document.body.classList.add('move-mode'); 
                canvases.forEach(c => c.style.pointerEvents = 'none'); 
                document.body.style.cursor = 'grab'; 
            } else { 
                document.body.classList.remove('move-mode'); 
                canvases.forEach(c => c.style.pointerEvents = 'auto'); 
                document.body.style.cursor = 'default'; 
                updateCursor(); 
            } 
        }
        
        function updateCursor() { 
            if(currentTool === 'move') return; 
            const canvases = document.querySelectorAll('canvas'); 
            if (currentTool === 'eraser') { 
                const size = eraserSize; 
                const strokeColor = document.body.classList.contains('dark-mode') ? '#fff' : '#000'; 
                const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size/2}" cy="${size/2}" r="${Math.max(1, size/2 - 1)}" fill="rgba(150,150,150,0.3)" stroke="${strokeColor}" stroke-width="1"/></svg>`; 
                const dataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; 
                const offset = Math.floor(size / 2); 
                canvases.forEach(c => { c.style.cursor = `url('${dataUrl}') ${offset} ${offset}, cell`; }); 
            } else { 
                canvases.forEach(c => { c.style.cursor = 'crosshair'; }); 
            } 
        }

        let isDrawing = false; 
        let currentCtx = null;
        
        function initDrawing() { 
            document.querySelectorAll('canvas').forEach(canvas => { 
                const size = parseInt(canvas.getAttribute('data-size')); 
                canvas.width = size * 2; 
                canvas.height = size * 2; 
                const ctx = canvas.getContext('2d'); 
                ctx.scale(2, 2); 
                canvas.addEventListener('mousedown', startDrawing); 
                canvas.addEventListener('mousemove', draw); 
                canvas.addEventListener('mouseup', stopDrawing); 
                canvas.addEventListener('mouseout', stopDrawing); 
                canvas.addEventListener('touchstart', startDrawing, {passive: false}); 
                canvas.addEventListener('touchmove', draw, {passive: false}); 
                canvas.addEventListener('touchend', stopDrawing, {passive: false}); 
            }); 
        }

        function getEventPos(e, canvas) { 
            const rect = canvas.getBoundingClientRect(); 
            const clientX = e.touches ? e.touches[0].clientX : e.clientX; 
            const clientY = e.touches ? e.touches[0].clientY : e.clientY; 
            return { 
                x: (clientX - rect.left) / currentZoom, 
                y: (clientY - rect.top) / currentZoom 
            }; 
        }
        
        function startDrawing(e) { 
            if(currentTool === 'move') return; 
            e.preventDefault(); 
            const charBlock = this.closest('.char-block'); 
            if (charBlock) { 
                const char = charBlock.getAttribute('data-char'); 
                const ruby = charBlock.getAttribute('data-ruby'); 
                
                if (char && char.trim() !== '') { 
                    let lang = 'ja-JP'; 
                    let textToSpeak = ruby ? ruby : char; 
                    // アルファベット単体のページ(Step1, 2)のみ英語エンジンで発音
                    if (currentScript === 'alphabet' && [1, 2].includes(currentPage)) lang = 'en-US'; 
                    speakChar(textToSpeak, lang); 
                } 
            } 
            isDrawing = true; 
            currentCtx = this.getContext('2d'); 
            currentCtx.beginPath(); 
            currentCtx.moveTo(getEventPos(e, this).x, getEventPos(e, this).y); 
        }

        function draw(e) { 
            if (!isDrawing || !currentCtx || currentTool === 'move') return; 
            e.preventDefault(); 
            const pos = getEventPos(e, this); 
            currentCtx.lineCap = 'round'; 
            currentCtx.lineJoin = 'round'; 
            
            if (currentTool === 'eraser') { 
                currentCtx.globalCompositeOperation = 'destination-out'; 
                currentCtx.lineWidth = eraserSize; 
                currentCtx.strokeStyle = 'rgba(0,0,0,1)'; 
            } else { 
                currentCtx.globalCompositeOperation = 'source-over'; 
                currentCtx.lineWidth = penSize; 
                const isDark = document.body.classList.contains('dark-mode');
                currentCtx.strokeStyle = isDark ? '#ffffff' : '#111111'; // ダークモード時は常に白で描画
            } 
            currentCtx.lineTo(pos.x, pos.y); 
            currentCtx.stroke(); 
        }
        
        function stopDrawing(e) { 
            if(e) e.preventDefault(); 
            isDrawing = false; 
            currentCtx = null; 
        }

        function initPanAndZoom() {
            let isPanning = false; 
            let startX, startY, scrollLeft, scrollTop; 
            let initialPinchDistance = null; 
            let initialZoomScale = 1.0;
            
            document.addEventListener('mousedown', (e) => { 
                if (currentTool !== 'move') return; 
                isPanning = true; 
                startX = e.pageX - document.documentElement.offsetLeft; 
                startY = e.pageY - document.documentElement.offsetTop; 
                scrollLeft = window.scrollX; 
                scrollTop = window.scrollY; 
                document.body.style.cursor = 'grabbing'; 
            });
            document.addEventListener('mousemove', (e) => { 
                if (!isPanning || currentTool !== 'move') return; 
                e.preventDefault(); 
                const x = e.pageX - document.documentElement.offsetLeft; 
                const y = e.pageY - document.documentElement.offsetTop; 
                window.scrollTo(scrollLeft - (x - startX), scrollTop - (y - startY)); 
            });
            document.addEventListener('mouseup', () => { 
                isPanning = false; 
                if(currentTool === 'move') document.body.style.cursor = 'grab'; 
            });
            
            document.addEventListener('touchstart', (e) => { 
                if (currentTool !== 'move') return; 
                if (e.touches.length === 1) { 
                    isPanning = true; 
                    startX = e.touches[0].pageX - document.documentElement.offsetLeft; 
                    startY = e.touches[0].pageY - document.documentElement.offsetTop; 
                    scrollLeft = window.scrollX; 
                    scrollTop = window.scrollY; 
                } else if (e.touches.length === 2) { 
                    isPanning = false; 
                    initialPinchDistance = Math.hypot(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY); 
                    initialZoomScale = currentZoom; 
                } 
            }, {passive: false});
            
            document.addEventListener('touchmove', (e) => { 
                if (currentTool !== 'move') return; 
                e.preventDefault(); 
                if (e.touches.length === 1 && isPanning) { 
                    const x = e.touches[0].pageX - document.documentElement.offsetLeft; 
                    const y = e.touches[0].pageY - document.documentElement.offsetTop; 
                    window.scrollTo(scrollLeft - (x - startX), scrollTop - (y - startY)); 
                } else if (e.touches.length === 2 && initialPinchDistance) { 
                    const currentDistance = Math.hypot(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY); 
                    let newZoom = initialZoomScale * (currentDistance / initialPinchDistance); 
                    if(newZoom < 0.5) newZoom = 0.5; 
                    if(newZoom > 2.5) newZoom = 2.5; 
                    currentZoom = newZoom; 
                    applyZoom(); 
                } 
            }, {passive: false});
            
            document.addEventListener('touchend', (e) => { 
                if (e.touches.length < 2) initialPinchDistance = null; 
                if (e.touches.length === 0) isPanning = false; 
            });
        }

        function clearCurrentPage() { 
            clearCanvasesInElement(document.querySelector('.page.active')); 
        }
        function clearAllPages() { 
            document.querySelectorAll(`.page[id^="page-${currentScript}-"]`).forEach(page => clearCanvasesInElement(page)); 
        }
        function clearCanvasesInElement(el) { 
            el.querySelectorAll('canvas').forEach(canvas => { 
                const ctx = canvas.getContext('2d'); 
                ctx.save(); 
                ctx.setTransform(1, 0, 0, 1, 0, 0); 
                ctx.clearRect(0, 0, canvas.width, canvas.height); 
                ctx.restore(); 
            }); 
        }

        function getDateString() { 
            const d = new Date(); 
            return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; 
        }
        function getBaseName() { 
            const handStr = currentHand === 'left' ? (currentLang === 'en' ? 'LeftHanded' : '左手用') : (currentLang === 'en' ? 'RightHanded' : '右手用'); 
            let scriptStr = ''; 
            if (currentScript === 'hiragana') scriptStr = currentLang === 'en' ? 'Hiragana' : 'ひらがな'; 
            else if (currentScript === 'katakana') scriptStr = currentLang === 'en' ? 'Katakana' : 'カタカナ'; 
            else if (currentScript === 'alphabet') scriptStr = currentLang === 'en' ? 'Romaji' : 'ローマ字'; 
            else scriptStr = currentLang === 'en' ? 'Numbers' : '数字'; 
            
            return currentLang === 'en' ? `${handStr}_${scriptStr}` : `${handStr}_${scriptStr}練習`; 
        }
        
        async function capturePage(pageEl) { 
            const bgColor = getComputedStyle(document.body).getPropertyValue('--bg-color').trim(); 
            const origRadius = pageEl.style.borderRadius; 
            const origShadow = pageEl.style.boxShadow; 
            pageEl.style.borderRadius = '0'; 
            pageEl.style.boxShadow = 'none'; 
            await new Promise(r => setTimeout(r, 50)); 
            const canvas = await html2canvas(pageEl, { scale: 2, backgroundColor: bgColor, useCORS: true }); 
            pageEl.style.borderRadius = origRadius; 
            pageEl.style.boxShadow = origShadow; 
            return canvas; 
        }

        function closeModal() {
            document.getElementById('image-modal').style.display = 'none';
            document.getElementById('modal-images').innerHTML = '';
        }
        
        async function saveCurrentPage() {
            document.getElementById('loading-overlay').style.display = 'flex';
            try {
                const tempZoom = currentZoom; 
                resetZoom(); 
                const format = document.getElementById('save-format').value; 
                const canvas = await capturePage(document.querySelector('.page.active')); 
                const fName = `${getBaseName()}_Page${currentPage}_${getDateString()}`;
                
                if (format === 'png') { 
                    if (isIOS) {
                        // iOS/iPadOSはモーダルで画像を表示し、長押し保存させる
                        const modalImages = document.getElementById('modal-images');
                        modalImages.innerHTML = '';
                        const img = document.createElement('img');
                        img.src = canvas.toDataURL('image/png');
                        modalImages.appendChild(img);
                        document.getElementById('image-modal').style.display = 'flex';
                    } else {
                        // PCやAndroid等はそのままダウンロード
                        const link = document.createElement('a'); 
                        link.download = `${fName}.png`; 
                        link.href = canvas.toDataURL('image/png'); 
                        link.click(); 
                    }
                } else if (format === 'pdf') { 
                    const { jsPDF } = window.jspdf; 
                    const pdf = new jsPDF({ orientation: 'landscape', format: 'a4' }); 
                    pdf.addImage(canvas.toDataURL('image/png'), 'PNG', 0, 0, 297, 210); 
                    pdf.save(`${fName}.pdf`); 
                }
                currentZoom = tempZoom; 
                applyZoom();
            } catch (err) { 
                console.error(err); 
                alert("Error while saving."); 
            } 
            finally { 
                document.getElementById('loading-overlay').style.display = 'none'; 
            }
        }

        async function saveAllPages() {
            document.getElementById('loading-overlay').style.display = 'flex';
            const format = document.getElementById('save-format').value; 
            const originalPage = currentPage; 
            const tempZoom = currentZoom; 
            resetZoom(); 
            const base = getBaseName(); 
            const allStr = currentLang === 'en' ? 'AllPages' : '全ページ'; 
            const dateStr = getDateString(); 
            const total = getTotalPages();
            
            try {
                let capturedCanvases = [];
                for (let i = 1; i <= total; i++) { 
                    document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); 
                    const pageEl = document.getElementById(`page-${currentScript}-${i}`); 
                    pageEl.classList.add('active'); 
                    capturedCanvases.push(await capturePage(pageEl)); 
                }
                
                if (format === 'png') { 
                    if (isIOS) {
                        // iOS/iPadOSはモーダルに全ページの画像を並べて表示する
                        const modalImages = document.getElementById('modal-images');
                        modalImages.innerHTML = '';
                        for (let idx = 0; idx < capturedCanvases.length; idx++) { 
                            const img = document.createElement('img');
                            img.src = capturedCanvases[idx].toDataURL('image/png');
                            modalImages.appendChild(img);
                        }
                        document.getElementById('image-modal').style.display = 'flex';
                    } else {
                        // PCやAndroid等はそのまま連続ダウンロード
                        for (let idx = 0; idx < capturedCanvases.length; idx++) { 
                            const link = document.createElement('a'); 
                            link.download = `${base}_Page${idx + 1}_${dateStr}.png`; 
                            link.href = capturedCanvases[idx].toDataURL('image/png'); 
                            link.click(); 
                            await new Promise(r => setTimeout(r, 600)); // 連続DLのフリーズ防止
                        }
                    }
                } else if (format === 'pdf') { 
                    const { jsPDF } = window.jspdf; 
                    let pdf = null; 
                    capturedCanvases.forEach((cvs, idx) => { 
                        if (idx === 0) pdf = new jsPDF({ orientation: 'landscape', format: 'a4' }); 
                        else pdf.addPage('a4', 'landscape'); 
                        pdf.addImage(cvs.toDataURL('image/png'), 'PNG', 0, 0, 297, 210); 
                    }); 
                    pdf.save(`${base}_${allStr}_${dateStr}.pdf`); 
                }
            } catch (err) { 
                console.error(err); 
                alert("Error while saving."); 
            } 
            finally { 
                currentPage = originalPage; 
                updatePageDisplay(); 
                currentZoom = tempZoom; 
                applyZoom(); 
                document.getElementById('loading-overlay').style.display = 'none'; 
            }
        }
    </script>
</body>
</html>

🔑コードの詳細解説

「ブラウザ上で動く、ひらがな・カタカナ・ローマ字・数字の書き取り練習アプリ(SPA:シングルページアプリケーション)」です。

1つのHTMLファイルの中に、HTML/CSS/JavaScriptがすべてまとまっており、サーバーなし(オフライン)でも動作する作りになっています。

このコードのすごいところや、どのような仕組みで動いているのかを分かりやすく解説します!


🌟アプリの主な特徴(実装されている機能)

  1. 4つの学習モード:
    ひらがな、カタカナ、ローマ字、数字

  2. 両利き対応:
    左手用・右手用で文字の並び順
    (お手本と書き込みマスの位置)が反転する

  3. お絵かき機能:
    ペン・消しゴムでの書き込み対応
    (タブレットのタッチ操作・PCのマウス操作両対応)

  4. 音声読み上げ:
    タップした文字や、ページ全体を
    ブラウザの機能(Web Speech API)で読み上げ

  5. 画像・PDF保存機能:
    書いた内容をPNG画像やPDFとしてダウンロード可能
    (iOS特有の保存制限にも対応)

  6. 多言語&テーマ対応:
    日本語/英語の切り替え、ライト/ダークモードの切り替え

  7. ズーム&移動:
    ピンチイン・アウト(拡大縮小)やスワイプでの画面移動に対応


🛠️ コードの構造解説

コードは大きく分けて、
①設定・ライブラリ読み込み
②CSS(デザイン)
③HTML(UI枠組み)
④JavaScript(動作ロジック)
の4ブロックで構成されています。

1. <head>:外部ライブラリとフォントの読み込み

  • Google Fonts:
    Klee One(教科書体のような手書き風フォント)と
    Noto Sans JP(UI用)を読み込んでいます。

  • FontAwesome:
    ボタンのアイコン(ペン、消しゴム、ゴミ箱など)を
    表示するためのライブラリです。

  • html2canvas / jsPDF:
    HTMLの画面をそのまま「画像」としてキャプチャし、
    それを「PDF」に変換して保存するための
    強力なJavaScriptライブラリです。

2. <style>:CSSデザインとレスポンシブ対応

  • CSS変数(:root):
    色の設定を変数化しており、
    .dark-mode クラスが付与されたときに
    変数の色を一斉に暗い色に切り替えることで、
    ダークモードを簡単に実現しています。

  • 左利き対応の工夫:
    css
    body.left-hand .char-block { flex-direction: row-reverse; }
    左手モードの時は、
    Flexboxの並び順を反転(row-reverse)させています。
    これにより、書いている自分の手で
    「お手本」が隠れないように配慮されています。

  • キャンバスの重ね合わせ:
    文字のマス目(.cell)の上に
    <canvas> タグを絶対配置(position: absolute)で重ねており、
    お手本をなぞれるようになっています。

3. <body>:HTMLの骨組み

画面に最初から存在しているのは、以下の3つのパーツだけです。

  • #loading-overlay:
    保存中などに画面を暗くして「処理中」を出す画面。

  • #image-modal:
    iPhone/iPad(iOS)の場合、
    直接ダウンロードが制限されることがあるため、
    「画像を長押しして保存してね」と表示する専用の画面。

  • #controls:
    画面上部に固定されているメニューバー。

  • #pages-container:
    練習帳のページが入る「空の箱」です。
    中身はすべてJavaScriptで生成されます。

4. <script>:JavaScriptの心臓部

非常に高度な処理が行われています。主要な機能をピックアップします。

  • データ管理(i18n, charsData):
    日本語/英語の翻訳データと、
    練習する文字のデータ(五十音やアルファベット、
    ヘボン式・訓令式の違いなど)がオブジェクトとして整理されています。

  • ページの動的生成(renderPages()):
    charsData のデータを元に、
    JavaScriptの for ループを使って
    大量のHTML(練習マスやCanvas)を自動生成し、
    #pages-container に流し込んでいます。

  • お絵かき機能(initDrawing(), startDrawing(), draw() など):
    <canvas> APIを利用しています。
    マウスやタッチの動き(mousemove, touchmove)を検知して、
    Canvas上に線を引きます。
    消しゴムモードの時は destination-out というモードにして
    線を透明化(消去)しています。

  • 音声読み上げ(speakText(), speakChar()):
    ブラウザ標準の SpeechSynthesisUtterance を使用。
    単に文字を渡すだけでなく、
    「は」を「ハ」と読ませる、記号を無視するなど、
    自然に発音させるための置換処理が細かく実装されています。

  • ピンチズーム・移動(initPanAndZoom()):
    指2本でのタッチ(e.touches.length === 2)の距離を
    ピタゴラスの定理(Math.hypot)で計算し、
    直感的な拡大縮小(ズーム)を実現しています。

  • 保存機能(saveCurrentPage(), saveAllPages()):
    html2canvas で画面を画像化します。ここでの素晴らしい工夫は、
    ユーザーの端末がiOSかどうかを判定(isIOS)している点です。
    iOSのブラウザ(Safariなど)は
    JavaScriptからの強制ダウンロードをブロックすることが多いため、
    iOSの時だけ「画像を画面に表示して、
    ユーザー自身に長押し保存させる」という
    安全なフォールバック(代替処理)を行っています。


👍総評

創作大賞2026オールカテゴリ部門【完全無料・神アプリ】多機能デジタル文字練習帳アプリ📱iPad・スマホ・PCでペン字練習🖊 #美文字 <a target=#アプリ #ai #Gemini #canvas  handwriting practice,handwriting app,digital handwriting,learn to write,penmanship,beautiful handwriting,free handwriting app,free learning app,AI app,Google Gemini,Gemini AI,AI development,iPad handwriting,Apple Pencil handwriting,iPad learning,iPad education app,writing practice app,education app,learning app,web app,Chrome app,no download app,EdTech,assistive technology,accessibility,inclusive design,rehabilitation,occupational therapy,left handed writing,right handed writing,ambidextrous,English writing practice,Japanese writing practice,language learning,kids learning app,Creative Awards 2026,Sosaku Taisho 2026,生成AI,創作大賞2026,美文字,文字練習" width="620" height="349">
創作大賞2026オールカテゴリ部門【完全無料・神アプリ】多機能デジタル文字練習帳アプリ📱iPad・スマホ・PCでペン字練習🖊 #美文字 #アプリ #ai #Gemini #canvas


このコードは、単なるWebページではなく
完成された教育系Webアプリです。

  • UX(ユーザー体験)への配慮:
    左利き対応、ダークモード、画面ズーム、読み上げ時の誤読防止
    (「は」の読み方など)、ヘボン式・訓令式の解説ページまで
    含まれている点など、学習者への思いやりが随所に見られます。

  • モダンな技術の活用:
    フレームワーク(ReactやVueなど)を使わずに、
    素のJavaScript(バニラJS)だけで状態管理、
    DOM生成、Canvas描画、端末判定までを美しく実装しています。


このHTMLファイルを
そのままブラウザ(ChromeやSafariなど)で開くだけで
すぐにフル機能で遊べる、非常に質の高いコードです!


🎁 こんな人にオススメ

  • ひらがなやカタカナ、ローマ字を覚え始めの小さなお子様👶

  • 市販のドリルだと手が汚れたり、文字が隠れてしまう左利きの方✍️

  • 紙を消費せず、タブレットでペーパーレス学習をさせたい親御さん📱

  • 正しい書き順や発音を学びたい、日本語学習中の外国人の方🌍

右腕が動かないという絶望の淵から、
AIという強力な相棒とともに生み出したこのアプリ。
教育的配慮とデジタルならではの機能が融合した、自信作です。

このアプリが、文字を学ぶ誰かの
「不便」を「楽しい」に変えるきっかけになれたら、
これほど嬉しいことはありません。

ぜひ、iPadやPCから触ってみてくださいね!
アプリはコチラ👇




■ コンテストタグ

#創作大賞2026 #オールカテゴリ部門

■ 左利き・エッセイ・人生

#左利き #エッセイ #リハビリ #健康 #闘病 #障害者 #障害 #バリアフリー #アクセシビリティ #ダイバーシティ #マイノリティ #ライフスタイル #生き方 #人生 #挑戦 #体験談 #モチベーション

■ 教育・子育て・知育

#知育 #子育て #育児 #教育 #学習 #勉強 #幼児教育 #小学生 #保育園 #幼稚園 #ICT教育 #EdTech #知育アプリ #ドリル #宿題 #キッズ #勉強垢 #日記

■ 文字・書写・語学

#日本語学習 #ひらがな #カタカナ #ローマ字 #数字 #ペン字 #美文字 #書道  #手書き #手書き文字 #習字 #日本語 #Japanese #日本語教育 #語学 #英語学習 #英語

■ iPad・デジタル活用

#iPad活用 #iPad #タブレット #ApplePencil #デジタル #ペーパーレス #ライフハック #便利ツール #おすすめアプリ #無料アプリ

■ AI・プログラミング・個人開発

#アプリ開発 #AIとやってみた #プログラミング #Webアプリ #HTML #JavaScript #AI #生成AI #ChatGPT #Gemini #エンジニア #テクノロジー #IT #プログラミング初心者 #UIUX #Webデザイン

■ 創作・小説・エンタメ

#創作 #小説 #ファンタジー #クリエイター #ファンアート #イラスト #アニメ #映像制作 #映画 #エンタメ

■ note定番・日常

#毎日note #note #日記 #コラム #日常 #スキしてみて #読書
#AIで遊ぼう #子どもの成長を感じるとき
#AIと始めてみた #スランプの乗り越え方



創作大賞2026💎光片-ヒカリノカケラ-|🐰Koroちゃん🌸兎耳コロ💖創作大賞2026💎光片-ヒカリノカケラ-Hikari no Kakera|note




いいなと思ったら応援しよう!

🐰Koroちゃん🌸兎耳コロ💖一期のプリンセス🍓Ichigo no Princess🌹👑 宜しければ、サポートお願い致します❤🐰🌈 これからも可愛いコロちゃんをお見せできればと思っております☺