スマホで手書き入力する「Hand Write Input」

【更新履歴】

 ・2026/1/31 バージョン1.0公開。


画像
入力画面

《このツールの概要》

・スマホ画面のテキスト入力方式といえば、
 フリック入力が一般的ですが、
 このツールでは、手書きで入力します。

・まあ、フリック以外の入力方式を試すために作った
 という感じですが、意外とはやるかもしれません。^^;

・入力したテキストは、
 コピーボタンを押すことで
 他のwebページなどで使用できる他、
 検索ボタンやAIボタンを押すことで
 Googleの検索結果を表示したり、
 ChatGptからの解答を表示することができます。

・ソースコードはこちら。↓

<!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">
    <title>Hand Write Input 1.0</title>
    <style>
        :root {
            --bg-color: #121212;
            --text-color: #fff;
            --panel-bg: #1e1e1e;
            --border-color: #333;
            
            /* ボタンカラー設定 (two_same_1_4.html準拠) */
            --col-ent: #ffd900;    /* 改行: 黄色 */
            --col-bs:  #d62828;    /* 削除: 赤 */
            --col-spc: #777777;    /* 空白: グレー */
            
            --col-ctl: #40e0d0;    /* 操作系: 水色 */
            
            --col-ai: #ff1493;     /* AI: ピンク */
            --col-sch: #00e676;    /* 検索: 若葉色 */
            --col-cpy: #00bfff;    /* コピー: 水色 */
            --col-pst: #ff8c00;    /* 貼付: オレンジ */
        }

        body {
            font-family: "Hiragino Kaku Gothic ProN", sans-serif;
            margin: 0;
            height: 100vh;
            display: flex;
            flex-direction: column;
            background-color: var(--bg-color);
            color: var(--text-color);
            touch-action: none;
            overflow: hidden;
        }

        /* --- 上部エリア(テキスト表示 + ボタン群)--- */
        #top-area {
            height: 160px; /* 約4行分確保 */
            padding: 10px;
            display: flex;
            gap: 8px;
            background: #000;
            border-bottom: 2px solid var(--border-color);
            box-sizing: border-box;
            flex-shrink: 0;
            transition: height 0.3s;
        }
        
        /* 全画面モード時 */
        #top-area.fullscreen {
            height: 100%;
            position: absolute;
            top: 0; left: 0; right: 0; bottom: 0;
            z-index: 100;
        }

        /* テキスト入力欄 */
        #input-wrapper {
            flex: 1;
            display: flex;
            flex-direction: column;
        }
        textarea {
            width: 100%;
            height: 100%;
            background: #fff;
            color: #000;
            border: 2px solid #555;
            border-radius: 6px;
            padding: 8px;
            font-size: 18px;
            line-height: 1.5;
            resize: none;
            box-sizing: border-box;
            outline: none;
        }
        
        /* ボタンコントロールパネル */
        #control-panel {
            width: 130px; /* ボタン群の幅 */
            display: grid;
            grid-template-columns: 1fr 1fr 1fr; /* 3列 */
            gap: 4px;
        }

        .btn-col {
            display: flex;
            flex-direction: column;
            gap: 4px;
        }

        button {
            border: none;
            border-radius: 4px;
            font-weight: bold;
            font-size: 14px;
            cursor: pointer;
            color: #fff;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            transition: opacity 0.1s;
        }
        button:active { opacity: 0.7; }
        
        /* 色の割り当て */
        .btn-ent { background: var(--col-ent); color: #000; font-size: 20px; flex: 1; }
        .btn-spc { background: var(--col-spc); color: #fff; font-size: 12px; flex: 1; }
        .btn-bs  { background: var(--col-bs);  color: #fff; font-size: 16px; flex: 1; }
        
        .btn-ctl { background: var(--col-ctl); color: #000; font-size: 16px; flex: 1; }
        
        .btn-cpy { background: var(--col-cpy); color: #fff; font-size: 12px; flex: 1; }
        .btn-pst { background: var(--col-pst); color: #fff; font-size: 12px; flex: 1; }
        .btn-sch { background: var(--col-sch); color: #fff; font-size: 12px; flex: 1; }
        .btn-ai  { background: var(--col-ai);  color: #fff; font-size: 12px; flex: 1; }

        /* --- 手書きエリア --- */
        #canvas-wrapper {
            flex: 1;
            position: relative;
            background: var(--panel-bg);
            margin: 10px;
            border-radius: 8px;
            border: 1px solid #444;
            touch-action: none;
        }
        canvas {
            display: block;
            width: 100%;
            height: 100%;
            border-radius: 8px;
            cursor: crosshair;
        }

        /* 候補表示バー */
        #candidate-bar {
            height: 40px;
            background: #222;
            display: flex;
            align-items: center;
            overflow-x: auto;
            padding: 0 10px;
            border-bottom: 1px solid #333;
            flex-shrink: 0;
        }
        .cand-chip {
            background: #444;
            color: #fff;
            padding: 5px 12px;
            border-radius: 15px;
            margin-right: 8px;
            font-size: 16px;
            white-space: nowrap;
            cursor: pointer;
        }
        
        /* ステータス表示 */
        #status-overlay {
            position: absolute;
            top: 10px;
            right: 10px;
            color: rgba(255,255,255,0.3);
            font-size: 12px;
            pointer-events: none;
        }
        
        /* クリアボタン(手書きエリア用) */
        .float-clear {
            position: absolute;
            bottom: 10px;
            left: 10px;
            background: rgba(255, 59, 48, 0.8);
            color: white;
            padding: 8px 16px;
            border-radius: 20px;
            font-size: 12px;
            z-index: 10;
        }

    </style>
</head>
<body>

<div id="top-area">
    <div id="input-wrapper">
        <textarea id="text-input" placeholder="手書きで入力..."></textarea>
    </div>
    
    <div id="control-panel">
        <div class="btn-col">
            <button class="btn-ent" onclick="insertChar('\n')">↵</button>
            <button class="btn-spc" onclick="insertChar(' ')">空白</button>
            <button class="btn-bs"  onclick="deleteChar()">⌫</button>
        </div>
        <div class="btn-col">
            <button class="btn-ctl" onclick="scrollText(-1)">▲</button>
            <button class="btn-ctl" onclick="toggleFull()">□</button>
            <button class="btn-ctl" onclick="scrollText(1)">▼</button>
        </div>
        <div class="btn-col">
            <button class="btn-cpy" onclick="copyText()">コピ</button>
            <button class="btn-pst" onclick="pasteText()">貼付</button>
            <button class="btn-sch" onclick="webSearch()">検索</button>
            <button class="btn-ai"  onclick="askAI()">AI</button>
        </div>
    </div>
</div>

<div id="candidate-bar">
    <span style="color:#666; font-size:12px;">認識候補:</span>
</div>

<div id="canvas-wrapper">
    <div id="status-overlay">入力待ち</div>
    <button class="float-clear" onclick="clearCanvas(true)">全消去</button>
    <canvas id="main-canvas"></canvas>
</div>

<script>
    // --- 要素取得 ---
    const textarea = document.getElementById('text-input');
    const canvas = document.getElementById('main-canvas');
    const ctx = canvas.getContext('2d');
    const statusDiv = document.getElementById('status-overlay');
    const candBar = document.getElementById('candidate-bar');
    const topArea = document.getElementById('top-area');

    // --- 変数 ---
    let isDrawing = false;
    let traces = []; 
    let currentStroke = { x: [], y: [], t: [] };
    let rect = canvas.getBoundingClientRect();
    let timer = null;
    let startTime = 0;

    // --- 初期化 ---
    function initCanvas() {
        rect = canvas.getBoundingClientRect();
        canvas.width = rect.width;
        canvas.height = rect.height;
        ctx.lineWidth = 5;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.strokeStyle = '#00BFFF'; // 筆跡は水色で見やすく
    }
    window.addEventListener('resize', initCanvas);
    setTimeout(initCanvas, 100);

    // --- 手書きイベント処理 (Pointer Events) ---
    canvas.addEventListener('pointerdown', startDrawing);
    canvas.addEventListener('pointermove', draw);
    canvas.addEventListener('pointerup', endDrawing);
    canvas.addEventListener('pointerleave', endDrawing);

    function startDrawing(e) {
        e.preventDefault();
        clearTimeout(timer);
        isDrawing = true;
        
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
        startTime = Date.now();

        currentStroke = { x: [x], y: [y], t: [0] };
        
        ctx.beginPath();
        ctx.moveTo(x, y);
        statusDiv.innerText = "描画中...";
    }

    function draw(e) {
        if (!isDrawing) return;
        e.preventDefault();
        
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
        const t = Date.now() - startTime;

        currentStroke.x.push(x);
        currentStroke.y.push(y);
        currentStroke.t.push(t);

        ctx.lineTo(x, y);
        ctx.stroke();
    }

    function endDrawing(e) {
        if (!isDrawing) return;
        isDrawing = false;
        
        if (currentStroke.x.length > 0) {
            traces.push([currentStroke.x, currentStroke.y, currentStroke.t]);
        }
        
        statusDiv.innerText = "認識待機中...";
        timer = setTimeout(recognize, 800); // 800msの無操作で認識開始
    }

    // --- API連携 ---
    function recognize() {
        if (traces.length === 0) return;
        statusDiv.innerText = "解析中...";

        const data = {
            app_version: 0.4,
            api_level: "537.36",
            device: window.navigator.userAgent,
            input_type: 0,
            options: "enable_pre_space",
            requests: [{
                writing_guide: { writing_area_width: canvas.width, writing_area_height: canvas.height },
                pre_context: textarea.value,
                max_num_results: 10,
                max_completions: 0,
                language: "ja",
                ink: traces
            }]
        };

        fetch('https://inputtools.google.com/request?itc=ja-t-i0-handwrit&app=demo', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data)
        })
        .then(response => response.json())
        .then(json => {
            if (json[0] === "SUCCESS") {
                const results = json[1][0][1];
                insertChar(results[0]); // 最有力候補を入力
                showCandidates(results); // 候補表示
                clearCanvas(false); // 筆跡のみクリア
                statusDiv.innerText = "完了";
            }
        })
        .catch(err => {
            console.error(err);
            statusDiv.innerText = "エラー";
        });
    }

    // --- UI操作関数 ---
    
    // 文字挿入
    function insertChar(char) {
        const start = textarea.selectionStart;
        const end = textarea.selectionEnd;
        const val = textarea.value;
        textarea.value = val.substring(0, start) + char + val.substring(end);
        textarea.selectionStart = textarea.selectionEnd = start + char.length;
        textarea.focus();
    }

    // 削除 (BS)
    function deleteChar() {
        const start = textarea.selectionStart;
        const end = textarea.selectionEnd;
        const val = textarea.value;
        if (start === end) {
            if (start === 0) return;
            textarea.value = val.substring(0, start - 1) + val.substring(end);
            textarea.selectionStart = textarea.selectionEnd = start - 1;
        } else {
            textarea.value = val.substring(0, start) + val.substring(end);
            textarea.selectionStart = textarea.selectionEnd = start;
        }
        textarea.focus();
    }

    // キャンバスクリア
    function clearCanvas(clearAll = false) {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        traces = [];
        if (clearAll) {
            statusDiv.innerText = "クリア";
            candBar.innerHTML = '<span style="color:#666; font-size:12px;">認識候補:</span>';
        }
    }

    // 候補表示
    function showCandidates(results) {
        candBar.innerHTML = "";
        results.forEach(char => {
            const chip = document.createElement('div');
            chip.className = 'cand-chip';
            chip.innerText = char;
            chip.onclick = () => {
                // 直前の入力を置換する簡易実装
                // (厳密なUndoには複雑な履歴管理が必要だが、ここでは直前の1文字/単語を差し替える動きを模倣)
                const val = textarea.value;
                // 簡易的に末尾を書き換える(本来はカーソル位置管理が必要)
                // ここでは「修正用」として、カーソル直前の文字を削除して挿入
                deleteChar(); 
                insertChar(char);
            };
            candBar.appendChild(chip);
        });
    }

    // --- ボタン群の機能 ---

    // コピー
    function copyText() {
        navigator.clipboard.writeText(textarea.value).then(() => {
            statusDiv.innerText = "コピーしました";
            setTimeout(() => statusDiv.innerText = "", 1000);
        });
    }

    // 貼り付け
    async function pasteText() {
        try {
            const text = await navigator.clipboard.readText();
            insertChar(text);
        } catch (err) {
            alert("貼り付け権限がありません");
        }
    }

    // 検索
    function webSearch() {
        const text = textarea.value;
        if (text) window.open(`https://www.google.com/search?q=${encodeURIComponent(text)}`, '_blank');
    }

    // AI
    function askAI() {
        const text = textarea.value;
        if (text) window.open(`https://chatgpt.com/?q=${encodeURIComponent(text)}`, '_blank');
    }

    // スクロール
    function scrollText(dir) {
        textarea.scrollTop += dir * 24; // 1行分程度スクロール
    }

    // 全画面切り替え
    function toggleFull() {
        topArea.classList.toggle('fullscreen');
        // 全画面時は手書きエリアを隠すなどの調整が必要だが、
        // ユーザー要望の「□ボタン」の挙動(文章全体を表示)として、
        // topAreaを画面いっぱいに広げる実装にしている。
    }
</script>
</body>
</html>

・ダウンロードされる方はこちら。↓


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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
スマホで手書き入力する「Hand Write Input」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1