JavaScriptで写真撮影+お絵かきツールを作ろう!

当ページのリンクには広告が含まれています。
  • URLをコピーしました!

開発レシピでは、私が実際にアプリを開発していく過程を「ひぃひぃ」言いながらお届けするコーナーです。

記念すべき第1回は、「写真撮影した画像にお絵かきできるツール」を作っていきます!

本当は「URLからQRコードを生成するアプリ」を予定していたのですが、思った以上に難しくて今回は断念しました(笑)

そのうち書きますが、初回から重めの内容だと読者も当方も心が折れそうなので、もう少しライトなテーマにしました。

ということで、今回は「写真撮影+お絵かきツール」をミニコデを使って開発していきましょう。

ミニコデを開きながら読むと理解しやすいですよ!

\ 今すぐタップ /

目次

写真撮影+お絵かきツールのプロンプト

イメージはカメラを起動して撮影し、撮影した画像に自由にお絵かきをするイメージです。

ついでに保存までできれば、パーフェクト!

ということで、難しいことは考えず、まずは以下のプロンプトをAIに伝えましょう。

JavaScript、CSS、HTMLでカメラ撮影と撮影した画像に絵をかける機能(ペン1種類で太さ調整あり、色は自由に選択)できるアプリを作成してください。作成した画像はダウンロードできるようにしてください。なお、スマホで使うのでレスポンシブ対応は必須とします。

多分、それなりのものが出来上がるはずです。

さて、プロンプトに「レスポンシブ対応」という単語を入れたので、一応、解説しておきます。

豆知識:レスポンシブ対応とは?

スマホは縦長、パソコンは横長など、画面サイズがデバイスによって違います。その違いに合わせてレイアウトを自動調整することを「レスポンシブ対応」といいます。

ミニコデはスマホ開発に特化しているため、プロンプトに「レスポンシブ対応」を入れておかないと、スマホで正しく表示されないことがあります。

生成AIは専門用語を理解できるので、プロンプトには適度に技術用語を入れると意図が伝わりやすいです。覚えておくと便利ですよ。

1回目のソースコードは60点

先ほどのプロンプトで生成されたソースコードがこちらです。

ソースコードはこちら
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
  <title>モバイル画像編集ツール(回転・圧縮・PNG/JPEG保存)</title>
  <style>
    :root{
      --bg:#0f1115; --panel:#181b22; --accent:#4da3ff; --text:#e9edf1; --muted:#aeb7c2; --border:#2a2f3a;
    }
    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0; background:var(--bg); color:var(--text); font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans JP", sans-serif;
      -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
    }
    .app{
      min-height:100%; display:flex; flex-direction:column; gap:12px; padding:12px;
    }
    header{display:flex; justify-content:space-between; align-items:center; gap:8px}
    header h1{font-size:16px; margin:0; font-weight:700; letter-spacing:.2px}
    header .hint{font-size:12px; color:var(--muted)}
    .stage{
      display:grid; gap:12px;
      grid-template-columns: 1fr;
    }
    .preview{
      background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:10px;
      display:flex; align-items:center; justify-content:center; min-height:45vh; position:relative; overflow:hidden;
    }
    canvas{max-width:100%; height:auto; border-radius:8px; background:#0b0d12}
    .controls{
      background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:12px; display:flex; flex-direction:column; gap:12px;
    }
    .row{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
    .row input[type="file"]{
      display:block; width:100%; padding:10px; border:1px dashed var(--border); border-radius:10px; background:#10131a; color:var(--muted);
    }
    .btn{
      appearance:none; border:1px solid var(--border); background:#111520; color:var(--text);
      padding:10px 14px; border-radius:10px; font-weight:600; cursor:pointer;
      transition:transform .05s ease, background .2s ease, border-color .2s ease;
    }
    .btn:active{transform:scale(.98)}
    .btn.primary{background:var(--accent); border-color:transparent; color:#0c1220}
    .btn.icon{width:44px; padding:10px; display:grid; place-items:center; font-size:18px}
    .field{display:flex; flex-direction:column; gap:6px; min-width:140px; flex:1}
    .label{font-size:12px; color:var(--muted); display:flex; justify-content:space-between}
    input[type="range"]{width:100%}
    select, input[type="number"]{
      width:100%; padding:10px 12px; border-radius:10px; border:1px solid var(--border); background:#111520; color:var(--text);
    }
    .spacer{flex:1}
    .footer{font-size:12px; color:var(--muted); text-align:center; padding:4px 0}
    @media (min-width: 900px){
      .stage{grid-template-columns: 1.1fr .9fr}
      .preview{min-height:60vh}
    }
    /* モバイルで下部操作しやすい余白 */
    @supports (padding:max(0px)) {
      .app{padding-bottom:max(12px, env(safe-area-inset-bottom))}
    }
  </style>
</head>
<body>
  <div class="app" role="application" aria-label="画像編集ツール">
    <header>
      <h1>画像編集ツール(回転・圧縮・PNG/JPEG保存)</h1>
      <div class="hint">スマホ対応・オフラインOK</div>
    </header>

    <section class="stage">
      <div class="preview" id="dropZone" aria-label="プレビュー領域(ドラッグ&ドロップ可)">
        <canvas id="previewCanvas"></canvas>
      </div>

      <div class="controls" aria-label="操作パネル">
        <div class="row">
          <input aria-label="画像を選択" type="file" id="fileInput" accept="image/*" />
        </div>

        <div class="row" role="group" aria-label="回転">
          <button class="btn icon" id="rotateLeft" title="左に90°回転" aria-label="左に90度回転">⟲</button>
          <button class="btn icon" id="rotateRight" title="右に90°回転" aria-label="右に90度回転">⟳</button>
          <div class="spacer"></div>
          <button class="btn" id="resetBtn" title="設定をリセット">リセット</button>
        </div>

        <div class="row">
          <div class="field">
            <div class="label"><span>サイズ圧縮(縮小%)</span><span><strong id="scaleVal">100%</strong></span></div>
            <input id="scaleRange" type="range" min="10" max="100" value="100" step="1" aria-label="サイズ圧縮率(%)"/>
          </div>
        </div>

        <div class="row">
          <div class="field">
            <div class="label"><span>JPEG品質</span><span><strong id="qualityVal">0.9</strong></span></div>
            <input id="qualityRange" type="range" min="0.1" max="1" value="0.9" step="0.05" aria-label="JPEG品質(0.1〜1)"/>
          </div>
        </div>

        <div class="row">
          <div class="field" style="max-width:220px">
            <div class="label"><span>保存形式</span></div>
            <select id="formatSelect" aria-label="保存形式">
              <option value="image/jpeg">JPEG(拡張子 .jpg)</option>
              <option value="image/png">PNG(拡張子 .png)</option>
            </select>
          </div>
          <div class="spacer"></div>
          <button class="btn primary" id="downloadBtn">ダウンロード</button>
        </div>

        <div class="row" style="font-size:12px;color:var(--muted)">
          <div>ヒント:</div>
          <ul style="margin:6px 0 0 18px; padding:0">
            <li>PNGは可逆。品質スライダーはPNGでは無効(無視)</li>
            <li>サイズ圧縮は縦横を同率で縮小します</li>
            <li>画像は端末内で処理(アップロード無し)</li>
          </ul>
        </div>
      </div>
    </section>

    <div class="footer">© 2025 画像編集ツール</div>
  </div>

  <script>
    (() => {
      const fileInput = document.getElementById('fileInput');
      const rotateLeftBtn = document.getElementById('rotateLeft');
      const rotateRightBtn = document.getElementById('rotateRight');
      const resetBtn = document.getElementById('resetBtn');
      const scaleRange = document.getElementById('scaleRange');
      const scaleVal = document.getElementById('scaleVal');
      const qualityRange = document.getElementById('qualityRange');
      const qualityVal = document.getElementById('qualityVal');
      const formatSelect = document.getElementById('formatSelect');
      const downloadBtn = document.getElementById('downloadBtn');

      const previewCanvas = document.getElementById('previewCanvas');
      const pctx = previewCanvas.getContext('2d');
      const dropZone = document.getElementById('dropZone');

      let img = new Image();
      let imgURL = null;
      let rotation = 0; // 0, 90, 180, 270(度)

      function revokeURL(){
        if(imgURL){ URL.revokeObjectURL(imgURL); imgURL = null; }
      }

      function loadFile(file){
        if(!file || !file.type.startsWith('image/')) return;
        revokeURL();
        imgURL = URL.createObjectURL(file);
        img.onload = () => {
          rotation = 0;
          scaleRange.value = 100;
          scaleVal.textContent = '100%';
          qualityRange.value = 0.9;
          qualityVal.textContent = '0.9';
          renderPreview();
        };
        img.onerror = () => { alert('画像の読み込みに失敗しました'); revokeURL(); };
        img.src = imgURL;
      }

      function deg2rad(d){ return d * Math.PI / 180; }

      function getOutputDims(srcW, srcH, rot){
        const r = ((rot % 360) + 360) % 360;
        const scale = parseInt(scaleRange.value, 10) / 100;
        let w = Math.max(1, Math.round(srcW * scale));
        let h = Math.max(1, Math.round(srcH * scale));
        if(r === 90 || r === 270){ return {w: h, h: w}; }
        return {w, h};
      }

      // プレビュー描画(画面サイズに合わせて縮小表示)
      function renderPreview(){
        if(!img || !img.src) {
          pctx.clearRect(0,0,previewCanvas.width, previewCanvas.height);
          return;
        }
        // プレビューは表示領域にフィット
        const zoneRect = dropZone.getBoundingClientRect();
        const maxW = Math.min(zoneRect.width - 20, 2000);
        const maxH = Math.min(zoneRect.height - 20, 2000);

        // 現在の出力サイズ(回転・圧縮後の仮想サイズ)
        const scale = parseInt(scaleRange.value, 10) / 100;
        const r = ((rotation % 360) + 360) % 360;
        const baseW = Math.round(img.naturalWidth * scale);
        const baseH = Math.round(img.naturalHeight * scale);
        const outW = (r === 90 || r === 270) ? baseH : baseW;
        const outH = (r === 90 || r === 270) ? baseW : baseH;

        // 表示用スケール
        const fit = Math.min(maxW / outW, maxH / outH, 1);
        const vw = Math.max(1, Math.round(outW * fit));
        const vh = Math.max(1, Math.round(outH * fit));

        previewCanvas.width = vw;
        previewCanvas.height = vh;

        pctx.save();
        pctx.clearRect(0,0,vw,vh);

        // 回転を中心基準で適用
        pctx.translate(vw/2, vh/2);
        pctx.rotate(deg2rad(r));

        // 回転後の描画オフセットを考慮
        const dw = Math.round(img.naturalWidth * scale);
        const dh = Math.round(img.naturalHeight * scale);

        // プレビューの縮尺は fit に合わせる必要があるので、追加スケール
        const previewScale = fit;
        pctx.scale(previewScale, previewScale);

        // 回転後のキャンバスに合わせて座標補正
        if(r === 90 || r === 270){
          pctx.drawImage(img, -dh/2, -dw/2, dh, dw);
        } else {
          pctx.drawImage(img, -dw/2, -dh/2, dw, dh);
        }

        pctx.restore();
      }

      function exportBlob(){
        if(!img || !img.src) return Promise.reject(new Error('画像が読み込まれていません'));
        const { w: outW, h: outH } = getOutputDims(img.naturalWidth, img.naturalHeight, rotation);

        const canvas = document.createElement('canvas');
        canvas.width = outW;
        canvas.height = outH;
        const ctx = canvas.getContext('2d');

        ctx.save();
        // キャンバス中心を基準に回転
        ctx.translate(outW/2, outH/2);
        const r = ((rotation % 360) + 360) % 360;
        ctx.rotate(deg2rad(r));

        const scale = parseInt(scaleRange.value, 10) / 100;
        const dw = Math.round(img.naturalWidth * scale);
        const dh = Math.round(img.naturalHeight * scale);

        if(r === 90 || r === 270){
          ctx.drawImage(img, -dh/2, -dw/2, dh, dw);
        } else {
          ctx.drawImage(img, -dw/2, -dh/2, dw, dh);
        }
        ctx.restore();

        const mime = formatSelect.value; // 'image/jpeg' or 'image/png'
        const quality = parseFloat(qualityRange.value);
        return new Promise((resolve) => {
          if(canvas.toBlob){
            // PNGはquality無視
            canvas.toBlob(blob => resolve(blob), mime, mime === 'image/jpeg' ? quality : undefined);
          } else {
            // 古いブラウザfallback
            const dataURL = canvas.toDataURL(mime, mime === 'image/jpeg' ? quality : undefined);
            resolve(dataURLtoBlob(dataURL));
          }
        });
      }

      function dataURLtoBlob(dataURL){
        const arr = dataURL.split(','), mime = arr[0].match(/:(.*?);/)[1];
        const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n);
        while(n--){ u8arr[n] = bstr.charCodeAt(n); }
        return new Blob([u8arr], {type: mime});
      }

      function download(blob){
        const ext = formatSelect.value === 'image/png' ? 'png' : 'jpg';
        const a = document.createElement('a');
        a.download = `edited_${Date.now()}.${ext}`;
        a.href = URL.createObjectURL(blob);
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
          URL.revokeObjectURL(a.href);
          a.remove();
        }, 250);
      }

      // イベント
      fileInput.addEventListener('change', (e) => {
        const file = e.target.files && e.target.files[0];
        loadFile(file);
      });

      // ドラッグ&ドロップ(任意)
      ['dragenter','dragover'].forEach(type => {
        dropZone.addEventListener(type, (e)=>{ e.preventDefault(); e.dataTransfer.dropEffect='copy'; dropZone.style.borderColor = 'var(--accent)'; }, false);
      });
      ;['dragleave','drop'].forEach(type => {
        dropZone.addEventListener(type, (e)=>{ e.preventDefault(); dropZone.style.borderColor = 'var(--border)'; }, false);
      });
      dropZone.addEventListener('drop', (e)=>{
        const file = e.dataTransfer.files && e.dataTransfer.files[0];
        loadFile(file);
      });

      rotateLeftBtn.addEventListener('click', ()=>{ rotation = (rotation - 90) % 360; renderPreview(); });
      rotateRightBtn.addEventListener('click', ()=>{ rotation = (rotation + 90) % 360; renderPreview(); });

      resetBtn.addEventListener('click', ()=>{
        rotation = 0;
        scaleRange.value = 100; scaleVal.textContent = '100%';
        qualityRange.value = 0.9; qualityVal.textContent = '0.9';
        formatSelect.value = 'image/jpeg';
        renderPreview();
      });

      scaleRange.addEventListener('input', ()=>{
        scaleVal.textContent = `${scaleRange.value}%`;
        renderPreview();
      });

      qualityRange.addEventListener('input', ()=>{
        qualityVal.textContent = `${parseFloat(qualityRange.value).toFixed(2)}`;
      });

      formatSelect.addEventListener('change', ()=>{
        const isPNG = formatSelect.value === 'image/png';
        qualityRange.disabled = isPNG; // PNGは品質無関係
        qualityVal.style.opacity = isPNG ? .5 : 1;
      });

      downloadBtn.addEventListener('click', async ()=>{
        try{
          const blob = await exportBlob();
          download(blob);
        }catch(err){
          alert(err.message || 'ダウンロードに失敗しました');
        }
      });

      // 初期状態:品質の表示を同期
      qualityVal.textContent = `${parseFloat(qualityRange.value).toFixed(2)}`;

      // 画面サイズ変化でプレビュー再描画
      let resizeTimer = null;
      window.addEventListener('resize', ()=>{
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(renderPreview, 100);
      }, {passive:true});
    })();
  </script>
</body>
</html>

ミニコデに貼り付けて実行してみましょう。

結果は写真撮影機能もお絵かき機能もちゃんと動作します!

ただし、問題が一つ。

反応しないし・・・

画像のダウンロードがうまくいきません。タップしても反応なし……。

ChatGPTはダウンロード機能のプログラムが苦手?

実は、これが意外と厄介なんです。

ダウンロード処理はOSによって微妙に違います。

WindowsのEdgeでは動くのに、AndroidのChromeやiOSのSafariでは動かない……なんてことがよくあります。

こうした互換性の問題が、生成AIでも完全には解決できない理由の一つです。

さて、私も理由はわかっても対処方法が思い浮かばなかったんですよね。おかげでそこそこ苦労したんですが、みなさんならどうしますか?

答えはかんたん。AIに聞けばいいのです。

ダウンロード問題を解決するプロンプトとは?

そこで、ChatGPTに質問してみることにしました。

ダウンロード処理はOSによって微妙に違います。WindowsのEdgeでは動くのに、AndroidのChromeやiOSのSafariでは動かない……なんてことがよくあります。
↑この問題を一発解決する最適なプロンプトを教えてください。

すると、こんなプロンプトが出てきました。

プロンプトはこちら
# 最適プロンプト(コピペ用)

**目的**
スマホ対応の「写真撮影+お絵かき」Webアプリを、各OSで確実に画像保存できる実装で作成してください。

**要件**
* 技術: HTML/CSS/JavaScript(フレームワークなし)。完全レスポンシブ。
* 機能:
  1. カメラ起動→撮影→キャンバスに表示
  2. キャンバスにペン1種(太さ調整・色選択)で落書き
  3. 画像保存(ダウンロード)
* 重要: 画像保存は OS/ブラウザ差異を吸収し、以下の順序で **機能検出** による分岐を実装すること(UA判定は禁止)。

  * A. **Web Share API(ファイル対応)** が使える場合
    * `canvas.toBlob('image/png')`→`new File([...], 'drawing-YYYYMMDD-hhmmss.png', { type:'image/png' })`
    * `navigator.canShare({ files:[file] })` を確認し、`navigator.share({ files:[file], title:'My Drawing' })` で共有/保存
  * B. **`a[download]` + `URL.createObjectURL`** が使える場合(デスクトップ/Android想定)
    * `toBlob`→`URL.createObjectURL(blob)`→不可視`<a download>`クリック→`URL.revokeObjectURL()`で解放
  * C. **iOS Safari 等のフォールバック**
    * `toDataURL('image/png')` を **新規タブ** で `window.open(dataURL, '_blank')`
    * 画面上に「長押しで”写真に追加”」等の説明をトースト/モーダルで表示

* 補助要件:
  * 端末判定は一切しない(必ず **feature detection**)。
  * すべて `async/await` で書き、`try/catch` でユーザーに失敗理由を通知。
  * メモリリーク回避のため、Blob URL は必ず `revokeObjectURL`。
  * 保存ファイル名は `drawing-YYYYMMDD-hhmmss.png`。
  * シンプルUI(ヘッダー、カメラ/色/太さ/消しゴム/保存ボタン)。
  * ペンはキャンバス座標にスケーリング対応(高DPIでも線幅が視覚的に一定)。
  * CSSはモバイルファースト(ボタンはタップ領域44px以上)。

**提出物**
 * 単一HTMLファイル(`<style>` と `<script>` 同梱)
 * 主要関数: `initCamera()`, `startDrawing()`, `stopDrawing()`, `draw()`, `saveImage()`
 * `saveImage()` 内で **A→B→C** の順に分岐。各分岐のコメントを明記。
 * 端末ごとの動作確認ポイントを末尾コメントに列挙(Edge/Android Chrome/iOS Safari)。

**受け入れ条件(パス/不合格の基準)**
 * Edge(Windows/最新) … クリックでPNGが即ダウンロードされる
 * Chrome(Android/最新) … クリックでPNGが即ダウンロードされる
 * Safari(iOS/最新) … 新規タブに画像が開き、長押しで保存できる(または共有シートが開く)
 * いずれもエラー時はUIでユーザーに説明(アラートやトースト)

な、ながぁ!でも、これがプロンプトの極意の一つなんですよね。

いつ、どこで、だれが、なにを、どのうように、どうしたいのかを明記すると開発の精度が格段にあがります。でも、これだと70点くらいのプロンプトかなと思います。

その辺の話はまた今度するとして、実際に試してみました。

動かしてみたら一発でできました。

カメラ開始⇒シャッターで撮影した画像をキャンバスでお絵描きが出来ます。

指でなぞると線が描けます。消しゴムは・・・絵がきえるだけで、描いた線が消せるわけではないみたいですが(苦笑)

保存ボタンを押下で、画像共有画面が開くので「Line Keep」に共有してみました。

ちゃんとLineKeepに送信されました。

本当は普通にファイルとしてダウンロードしてほしかったんですけどね。

まあ、要件は満たしているので良しとしましょうか。一応ソースコードも載せておきます。

ソースコードはこちら
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>写真撮影+お絵かき(保存は機能検出 A→B→C)</title>
<style>
  :root{
    --bg:#0f172a;         /* slate-900 */
    --panel:#111827;      /* gray-900 */
    --muted:#334155;      /* slate-600 */
    --text:#e5e7eb;       /* gray-200 */
    --accent:#22c55e;     /* green-500 */
    --danger:#ef4444;     /* red-500 */
  }
  *{ box-sizing: border-box; }
  html,body{ height:100%; }
  body{
    margin:0;
    background:linear-gradient(180deg,var(--bg),#020617);
    color:var(--text);
    font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
  }
  header{
    position:sticky; top:0; z-index:10;
    background:rgba(2,6,23,0.8); backdrop-filter: blur(8px);
    border-bottom: 1px solid #1f2937;
    padding: 12px 16px;
    display:flex; align-items:center; gap:12px; flex-wrap:wrap;
  }
  header h1{
    font-size:18px; margin:0; letter-spacing:.02em; font-weight:700;
  }
  .wrap{
    max-width: 960px;
    margin: 0 auto;
    padding: 12px 16px 64px;
    display:grid;
    gap:12px;
  }
  .panel{
    background: #0b1220;
    border: 1px solid #1f2937;
    border-radius: 16px;
    padding: 12px;
    box-shadow: 0 10px 30px rgba(0,0,0,.35);
  }
  .video-box, .canvas-box{
    position: relative;
    aspect-ratio: 3/4;     /* 縦長デフォ(スマホ縦)*/
    width: 100%;
    background:#0a0f1b;
    border-radius: 12px;
    overflow:hidden;
    display:grid; place-items:center;
  }
  video{
    width:100%; height:100%;
    object-fit: cover;
    background:#000;
  }
  canvas{
    width:100%; height:100%;
    touch-action: none; /* ペン操作優先 */
    background:#000;    /* まだ写真が無い時の下地 */
  }

  .controls{
    display:grid; gap:8px;
    grid-template-columns: repeat(3,minmax(0,1fr));
  }
  .controls .row{ grid-column: 1/-1; display:flex; gap:8px; flex-wrap:wrap; }

  button, .btn{
    appearance:none; border:1px solid #1f2937;
    background: #0f172a;
    color: var(--text);
    padding: 12px 14px;
    min-height: 44px;      /* モバイル44px以上 */
    border-radius: 12px;
    font-weight: 700;
    width:100%;
    touch-action: manipulation;
  }
  button.primary{ background: #14532d; border-color:#14532d; }
  button.accent{ background: #065f46; border-color:#065f46; }
  button.danger{ background: #7f1d1d; border-color:#7f1d1d; }
  button.ghost{ background:#0b1220; }
  button:disabled{ opacity:.5; }

  .pickers{
    display:flex; gap:8px; align-items:center; flex-wrap:wrap;
  }
  input[type="color"]{
    width:52px; height:44px; padding:0; border-radius:12px; border:1px solid #1f2937;
    background: #0f172a;
  }
  input[type="range"]{ width: 180px; max-width: 100%; }

  .toolbar{
    display:grid; gap:8px;
    grid-template-columns: repeat(2, minmax(0,1fr));
  }
  .toolbar .wide{ grid-column: 1/-1; }

  .toast{
    position: fixed; left:50%; bottom: 20px; transform: translateX(-50%);
    background: rgba(15,23,42,.95);
    color: var(--text);
    border: 1px solid #1f2937;
    border-radius: 14px;
    padding: 12px 14px;
    font-size: 14px;
    max-width: min(92vw, 520px);
    z-index: 999;
    box-shadow: 0 8px 30px rgba(0,0,0,.5);
    opacity:0; pointer-events:none; transition: opacity .25s ease;
  }
  .toast.show{ opacity:1; pointer-events:auto; }
  .hint{ color:#9ca3af; font-size:12px; margin-top:4px; }

  /* レイアウト(広い画面) */
  @media (min-width: 840px){
    .grid-2{
      display:grid; gap:12px; grid-template-columns: 1.2fr .8fr;
      align-items: start;
    }
    .controls{ grid-template-columns: repeat(4,minmax(0,1fr)); }
  }
</style>
</head>
<body>
  <header>
    <h1>写真撮影+お絵かき</h1>
    <div class="hint">保存は <strong>A: Web Share(ファイル)</strong> → <strong>B: a[download]</strong> → <strong>C: dataURL新規タブ</strong> の順で機能検出</div>
  </header>

  <main class="wrap grid-2">
    <section class="panel">
      <div class="video-box" id="videoBox">
        <video id="video" playsinline autoplay muted></video>
      </div>
      <div class="controls" style="margin-top:8px">
        <button id="btnStart" class="primary">カメラ開始</button>
        <button id="btnShot"  class="accent">シャッター</button>
        <button id="btnClear" class="ghost">キャンバス初期化</button>
        <button id="btnSave"  class="accent">保存</button>
      </div>
    </section>

    <section class="panel">
      <div class="canvas-box">
        <canvas id="canvas"></canvas>
      </div>

      <div class="toolbar" style="margin-top:8px">
        <div class="pickers wide">
          <label>色:</label>
          <input type="color" id="color" value="#22c55e" aria-label="ペンの色" />
          <label>太さ:</label>
          <input type="range" id="size" min="1" max="40" step="1" value="6" aria-label="ペンの太さ" />
          <span id="sizeLabel">6px</span>
        </div>
        <button id="btnPen">ペン</button>
        <button id="btnEraser">消しゴム</button>
      </div>
    </section>
  </main>

  <div id="toast" class="toast" role="status" aria-live="polite"></div>

<script>
(() => {
  // ====== DOM 参照 ======
  const video = document.getElementById('video');
  const canvas = document.getElementById('canvas');
  const btnStart = document.getElementById('btnStart');
  const btnShot  = document.getElementById('btnShot');
  const btnSave  = document.getElementById('btnSave');
  const btnClear = document.getElementById('btnClear');
  const btnPen   = document.getElementById('btnPen');
  const btnEraser= document.getElementById('btnEraser');
  const colorEl  = document.getElementById('color');
  const sizeEl   = document.getElementById('size');
  const sizeLabel= document.getElementById('sizeLabel');
  const toastEl  = document.getElementById('toast');

  let stream = null;
  let ctx = canvas.getContext('2d');
  let drawing = false;
  let isEraser = false;
  let last = { x: 0, y: 0 };
  let dpr = Math.max(1, window.devicePixelRatio || 1);

  // ====== ユーティリティ ======
  function showToast(msg, timeout = 2600){
    toastEl.textContent = msg;
    toastEl.classList.add('show');
    clearTimeout(showToast._t);
    showToast._t = setTimeout(() => toastEl.classList.remove('show'), timeout);
  }

  function formatFilename(){
    const pad = (n)=> String(n).padStart(2,'0');
    const d = new Date();
    const YYYY = d.getFullYear();
    const MM   = pad(d.getMonth()+1);
    const DD   = pad(d.getDate());
    const hh   = pad(d.getHours());
    const mm   = pad(d.getMinutes());
    const ss   = pad(d.getSeconds());
    return `drawing-${YYYY}${MM}${DD}-${hh}${mm}${ss}.png`;
  }

  function resizeCanvasToBox(){
    const rect = canvas.getBoundingClientRect();
    // 高DPI対応: 実ピクセルをdpr倍にし、座標はCSS pxで扱うためscale
    canvas.width  = Math.max(1, Math.floor(rect.width  * dpr));
    canvas.height = Math.max(1, Math.floor(rect.height * dpr));
    ctx.setTransform(1,0,0,1,0,0); // reset
    ctx.scale(dpr, dpr);
    // 初期背景(未撮影時は黒で塗り)
    ctx.fillStyle = '#000';
    ctx.fillRect(0,0,rect.width, rect.height);
  }

  // ====== 主要関数: カメラ初期化 ======
  async function initCamera(){
    try{
      if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
        throw new Error('この端末/ブラウザはカメラ取得に未対応です。');
      }
      // できれば背面カメラ
      stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: { ideal: 'environment' } },
        audio: false
      });
      video.srcObject = stream;
      await video.play();
      showToast('カメラを開始しました。');
    }catch(err){
      console.error(err);
      alert(`カメラ起動に失敗しました: ${err.message || err}`);
    }
  }

  // ====== 写真をキャンバスへ取り込み ======
  async function captureToCanvas(){
    try{
      if(!video.videoWidth || !video.videoHeight){
        throw new Error('動画の準備ができていません。数秒待ってから再試行してください。');
      }
      // キャンバスを動画アスペクトに近づける(現在のキャンバスサイズに収めて描画)
      const rect = canvas.getBoundingClientRect();
      const vw = video.videoWidth;
      const vh = video.videoHeight;
      const arVideo = vw / vh;
      const arCanvas= rect.width / rect.height;

      let dw, dh, dx, dy;
      if(arVideo > arCanvas){
        // 横長 → 幅フィット
        dw = rect.width; dh = rect.width / arVideo;
        dx = 0; dy = (rect.height - dh)/2;
      }else{
        // 縦長 → 高さフィット
        dh = rect.height; dw = rect.height * arVideo;
        dy = 0; dx = (rect.width - dw)/2;
      }

      // 背景を黒でクリア
      ctx.save();
      ctx.setTransform(dpr,0,0,dpr,0,0); // 念のためdpr維持
      ctx.fillStyle = '#000';
      ctx.fillRect(0,0,rect.width, rect.height);
      ctx.drawImage(video, 0,0,vw,vh, dx,dy,dw,dh);
      ctx.restore();

      showToast('写真をキャンバスへ取り込みました。');
    }catch(err){
      console.error(err);
      alert(`取り込みに失敗しました: ${err.message || err}`);
    }
  }

  // ====== 主要関数: 描画開始/終了/描画 ======
  function getPosFromEvent(e){
    const rect = canvas.getBoundingClientRect();
    const pt = ('touches' in e && e.touches.length) ? e.touches[0] : e;
    const x = (pt.clientX - rect.left);
    const y = (pt.clientY - rect.top);
    return { x, y };
  }

  function startDrawing(e){
    e.preventDefault();
    drawing = true;
    const {x,y} = getPosFromEvent(e);
    last = {x,y};
    ctx.save();
    ctx.globalCompositeOperation = isEraser ? 'destination-out' : 'source-over';
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.strokeStyle = isEraser ? 'rgba(0,0,0,1)' : colorEl.value;
    ctx.lineWidth  = Number(sizeEl.value); // dprスケール済の座標系なのでCSS px指定でOK
    ctx.beginPath();
    ctx.moveTo(x, y);
  }

  function draw(e){
    if(!drawing) return;
    e.preventDefault();
    const {x,y} = getPosFromEvent(e);
    ctx.lineTo(x, y);
    ctx.stroke();
    last = {x,y};
  }

  function stopDrawing(){
    if(!drawing) return;
    drawing = false;
    ctx.closePath();
    ctx.restore();
  }

  // ====== 主要関数: 画像保存(A→B→C 順で機能検出) ======
  async function saveImage(){
    try{
      const fileName = formatFilename();

      // Canvas → Blob(PNG)
      const blob = await new Promise((resolve, reject) =>
        canvas.toBlob(b => b ? resolve(b) : reject(new Error('Blobの生成に失敗しました。')), 'image/png')
      );

      // ===== A. Web Share API(ファイル対応)=====
      // 条件: navigator.share && navigator.canShare && File が使用可能、かつ files を共有可能
      if (navigator.share && navigator.canShare && typeof navigator.canShare === 'function' && window.File) {
        try{
          const file = new File([blob], fileName, { type: 'image/png' });
          if (navigator.canShare({ files: [file] })) {
            await navigator.share({ files: [file], title: 'My Drawing', text: '' });
            showToast('共有(保存可)ダイアログを開きました。');
            return; // A 経路で終了
          }
        }catch(e){
          // Web Shareが存在しても、実行時に失敗する場合がある(ユーザーキャンセル等)
          console.warn('Web Share API failed or not permitted:', e);
          // フォールバック継続
        }
      }

      // ===== B. a[download] + URL.createObjectURL =====
      // 条件: download属性対応 && createObjectURL対応(デスクトップ/Android想定)
      if ('download' in HTMLAnchorElement.prototype && window.URL && URL.createObjectURL) {
        const url = URL.createObjectURL(blob);
        try{
          const a = document.createElement('a');
          a.href = url;
          a.download = fileName;
          // 不可視クリック
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          // メモリリーク回避:少し待ってから解放
          setTimeout(()=> URL.revokeObjectURL(url), 1000);
          showToast('PNGをダウンロードしました。');
          return; // B 経路で終了
        }catch(e){
          console.warn('a[download] failed:', e);
          URL.revokeObjectURL(url);
          // フォールバック継続
        }
      }

      // ===== C. iOS Safari 等のフォールバック =====
      // 条件: 上記が不可 → dataURLを新規タブで開く(長押し保存のガイダンスも表示)
      const dataURL = canvas.toDataURL('image/png');
      // ポップアップはユーザー操作中の同期呼び出しが望ましい
      const w = window.open(dataURL, '_blank');
      if(!w){
        // もしブロックされたら、現在タブ遷移で開く
        location.href = dataURL;
      }
      showToast('新規タブに画像を開きました。長押しで「写真に追加」等から保存してください。', 4200);
    }catch(err){
      console.error(err);
      alert(`保存に失敗しました: ${err.message || err}`);
    }
  }

  // ====== イベント配線 ======
  btnStart.addEventListener('click', initCamera);
  btnShot .addEventListener('click', captureToCanvas);
  btnSave .addEventListener('click', saveImage);
  btnClear.addEventListener('click', () => {
    const rect = canvas.getBoundingClientRect();
    ctx.save();
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.fillStyle = '#000';
    ctx.fillRect(0,0,rect.width, rect.height);
    ctx.restore();
  });

  btnPen.addEventListener('click', () => {
    isEraser = false;
    showToast('ペンモード');
  });
  btnEraser.addEventListener('click', () => {
    isEraser = true;
    showToast('消しゴムモード');
  });

  sizeEl.addEventListener('input', () => sizeLabel.textContent = `${sizeEl.value}px`);

  // Pointer/Touch/Mouse(Pointer Eventsを使用)
  canvas.addEventListener('pointerdown', startDrawing);
  canvas.addEventListener('pointermove',  draw);
  window.addEventListener('pointerup',    stopDrawing);
  canvas.addEventListener('pointerleave', stopDrawing);

  // リサイズ時にキャンバス物理解像度を更新(高DPI対応)
  const ro = new ResizeObserver(() => resizeCanvasToBox());
  ro.observe(canvas);

  // 初期セットアップ
  resizeCanvasToBox();

  // ページ離脱時はカメラ停止して解放
  window.addEventListener('beforeunload', () => {
    if(stream){
      stream.getTracks().forEach(t => t.stop());
    }
  });

  // ====== 公開(要件の関数名をグローバルへ) ======
  window.initCamera   = initCamera;
  window.startDrawing = startDrawing;
  window.stopDrawing  = stopDrawing;
  window.draw         = draw;
  window.saveImage    = saveImage;
})();
</script>

<!--
==== 端末ごとの動作確認ポイント ====

■ Edge (Windows / 最新)
- [保存]クリック → Aが使えなければBへフォールバックし、PNGが即ダウンロードされること
- a[download] + URL.createObjectURL が機能し、オブジェクトURLは revokeObjectURL されること
- エラー時はトースト/アラートで理由提示

■ Chrome (Android / 最新)
- 多くの端末で B(a[download])が動作し、PNGが即ダウンロードされる
- 一部端末では A(Web Share APIファイル対応)が true の場合、共有シートが開き保存可能
- どちらも不可なら C に回り、新規タブが開く

■ Safari (iOS / 最新)
- A(navigator.canShare({files}))をサポートする端末では共有シートが開く(保存可)
- それが不可の場合、C フォールバック:新規タブに dataURL 画像が開く
  → 「長押しで”写真に追加”」等のガイダンスのトーストが出る

■ 共通
- カメラ起動:getUserMedia が使えない場合はエラーメッセージ
- ペンは高DPIでも視覚的に一定の線幅(ctx.scale(dpr,dpr) でCSS px基準)
- Blob URLはダウンロード後に必ず revokeObjectURL
- すべて async/await + try/catch で失敗理由を通知
- ユーザーエージェントによる端末判定は一切未使用(機能検出のみ)

-->
</body>
</html>

Android、iOS、Windowsのブラウザでちゃんと動く!

ダウンロードもいけました!

まとめ

AIに開発してもらうときに行き詰まったら、AIに最適なプロンプトを生成してもらって、作り直してもらいましょう。

AIのつくるプロンプトで理解したと思うのですが、思い通りのアプリを作ってもらうなら具体的に書いたほうが正解です。

まあ、これ規模感が小さいからこれで解決できましたが、大き目のアプリを使い始めると結構ハマるんだな、これが。

それはまた別のお話で。

残念パパいのっちでした!

では、また!

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次