5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

絵しりとりLINE Botを作ろう🍌

Last updated at Posted at 2025-12-03

こんにちは!LINE Developer Community 2025年のアドベントカレンダーの3日目担当のうえぞうです。

絵しりとりLINE Bot、実は一昨年2023年のアドベントカレンダーでも作りましたが、これをパワーアップしていきたいと思います💪

  • AIの画力が進化: dalle-3 → gpt-image-1またはnanobanana🍌
  • 手描きできるように進化: LINEアプリ内で完結できるように

それでは、やっていきましょう!

🥰 アイコン画像の作成

これを最初にやると開発モチベーションが上がるという効果が得られますので、最初にやります。nano bananaにお願いしました。

🦜 画像を受け取ってAIターンを返すまでを作る

LINE Developersでの設定とかはこのへん見て!
https://qiita.com/cog1t0/items/0d121ee6d48a8c1634fc

Channel Access TokenとChannel Secretを控えておいてね。あとOpenAIのAPIキーも準備しておいてください。

はじめに必要なライブラリーをインストールします。

``sh
pip install line-bot-sdk openai uvicorn fastapi


コードは以下の通り。`run.py`に以下を記述し、また、画像保存先として`images`ディレクトリーを作成しておきましょう。解説はコメント見てね!

```python

# あとで使うやつも含めて最初から定義しておこう
from contextlib import asynccontextmanager
import base64
import aiohttp
from linebot import AsyncLineBotApi, WebhookParser
from linebot.aiohttp_async_http_client import AiohttpAsyncHttpClient
from linebot.models import TextSendMessage, ImageSendMessage
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
from fastapi.staticfiles import StaticFiles
from openai import AsyncClient
from pydantic import BaseModel
from uuid import uuid4

# 画像取得・アップロードのためのURL。ドメインは書き換えてね
YOUR_DOMAIN = "your-domain.com"
IMAGE_BASE_URL = f"https://{YOUR_DOMAIN}/images"

# LINE Developersで取得してね
YOUR_CHANNEL_ACCESS_TOKEN = "YOUR_CHANNEL_ACCESS_TOKEN"
YOUR_CHANNEL_SECRET = "YOUR_CHANNEL_SECRET"

# OpenAIで取得してね
OPENAI_API_KEY = "sk-proj-xxxxxx"

# システムプロンプト。しりとりのルールを教えてあげているよ
SYSTEM_CONTENT = """
あなたはユーザーとしりとりをしています。以下の条件に基づいて応答してください。

## 会話の条件

- ユーザーから示された画像は、ユーザーのターンの単語です。
- その画像に写っているものを認識します。画像は写真ではなくユーザーによるドローイングの場合もあります。たとえば寿司が写っていれば、ユーザーのターンの単語は「寿司」です。
- 次に、あなたのターンの単語を考えます。たとえばユーザーのターンの単語が「寿司」であれば、その読みは「スシ」であり、読みの末尾は「シ」です。そのため、あなたの単語は「シカ」や「シロネコ」になります。
- あなたのターンの単語だけを応答します。たとえば「シカ」や「シロネコ」だけを応答します。検討過程やその他の文言を入れることは許されません。
- ユーザーのターンの単語を応答することは厳禁です。たとえばコーヒーの画像を見せられて「コーヒー」と回答することは許されません。
- ユーザーから示された画像の意味がわからなくても、聞き返さずに単語を推定してください。


## 会話の例

user: スシ
assistant: シカ
user: カイ
assistant: イカ
user: カラス
assistant: スミレ
"""

# OpenAIのクライアント
openai_client = AsyncClient(api_key=OPENAI_API_KEY)

# LINE Messagin API関連のクライアントとか
session = aiohttp.ClientSession()
client = AiohttpAsyncHttpClient(session)
line_api = AsyncLineBotApi(
    channel_access_token=YOUR_CHANNEL_ACCESS_TOKEN,
    async_http_client=client
)
parser = WebhookParser(channel_secret=YOUR_CHANNEL_SECRET)

# LINEからのメッセージのハンドラー
async def handle_events(events):
    for ev in events:
        if ev.message.type == "image":
            # メッセージから画像を取得
            if ev.message.content_provider.type == "line":
                # LINEアプリでアップロードした画像の取得
                image_stream = await line_api.get_message_content(ev.message.id)
                with open(f"images/{ev.message.id}_req.png", "wb") as f:
                    async for chunk in image_stream.iter_content():
                        f.write(chunk)
                        request_image_url = f"{IMAGE_BASE_URL}/{ev.message.id}_req.png"
            else:
                # LIFFで送った画像の取得
                request_image_url = ev.message.content_provider.original_content_url

            # ChatGPTに画像を送信して、AI側のターンの単語を取得
            chatgpt_resp = await openai_client.chat.completions.create(
                model="gpt-5.1",
                messages=[
                    {
                        "role": "system", "content": SYSTEM_CONTENT
                    },
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "これは私のターンの画像です。あなたのターンの単語を考えてください。"},
                            {"type": "image_url", "image_url": {"url": request_image_url}}
                        ]
                    }
                ]
            )
            assistant_word = chatgpt_resp.choices[0].message.content

            # まずは単純にAIターンの単語を応答
            await line_api.reply_message(
                ev.reply_token,
                TextSendMessage(text=assistant_word)
            )

        else:
            # 画像メッセージ以外に対しては画像を要求する応答
            await line_api.reply_message(
                ev.reply_token,
                TextSendMessage(
                    text="画像を送ってね"
                )
            )


# FastAPIアプリの起動。終了時にLINEのクライアントを閉じたりする
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await session.close()

app = FastAPI(lifespan=lifespan)
app.mount("/images", StaticFiles(directory="images"), name="images")

# LINEからのWebhookの受付。さっさとokを返して、イベントの処理はバックグラウンドに流す
@app.post("/linebot")
async def handle_request(request: Request, background_tasks: BackgroundTasks):
    events = parser.parse(
        (await request.body()).decode("utf-8"),
        request.headers.get("X-Line-Signature", "")
    )
    background_tasks.add_task(handle_events, events=events)
    return "ok"

起動はこんな感じ。

uvicorn run:app

画像を送ってみましょう!上手く行ってたらこんな感じです。お蕎麦→バナナ、ですね。

IMG_7199.jpg

🍌 画像を返すようにする

パワーアップポイントの一つめ、Dalle-3からgpt-image-1にします。
まずは画像を生成して保存し、LINEサーバーからアクセスするためのURLを返す関数を作りましょう。

引数にとってるkeywordは、AIターンの単語。つまりこの画像を作ってくれということですね。
image_idは保存先ファイル名とURLに使用する一意のID。

nano bananaが使いたければ、ここの画像生成処理をnano bananaに変更すればOK👍

# Generate image with gpt-image-1 (You can change here to use nano banana🍌)
async def generate_image(keyword: str, image_id: str) -> str:
    image_resp = await openai_client.images.generate(
        model="gpt-image-1",
        prompt=f"{keyword}. Quick and simple drawing with a bold pen."
    )
    image_base64 = image_resp.data[0].b64_json
    image_bytes = base64.b64decode(image_base64)
    with open(f"images/{image_id}.png", "wb") as f:
        f.write(image_bytes)
    return f"{IMAGE_BASE_URL}/{image_id}.png"

次に、これを先ほどのLINE Botに繋ぎます。単語テキストじゃなくて、それを使用して画像を生成して返すようにしましょう。

                :(
            assistant_word = chatgpt_resp.choices[0].message.content

            # コメントアウト。消してもいいよ
            # # まずは単純にAIターンの単語を応答
            # await line_api.reply_message(
            #     ev.reply_token,
            #     TextSendMessage(text=assistant_word)
            # )

            # 画像を生成してURLを取得
            image_url = await generate_image(keyword=assistant_word, image_id=f"{ev.message.id}_res")

            # 画像を応答
            await line_api.reply_message(
                ev.reply_token,
                ImageSendMessage(
                    original_content_url=image_url,
                    preview_image_url=image_url
                )
            )

再起動して、同じお蕎麦の画像を送ってみると・・・

IMG_7201.jpg

バナナの絵だよ!やったね✌️
結構時間がかかるので、ぐるぐるとか表示したほうがいいかもしれない。
このへんを参考にして実装してみよう!

🎨 手描きに対応する

パワーアップポイントの二つめ。手描き。これができると無限に絵しりとりできます。
実現方法ですが、LINEにはLIFFと呼ばれる、LINE Botの中で使える小さなブラウザアプリケーションのための仕組みがあります。そのアプリケーションからチャットにメッセージを送信することができるため、今回はそのWebアプリケーションの中でタッチで手描きし、それをメッセージ送信しちゃおうというわけです。

それでは、先にサーバーサイドを準備します。手書き画像をアップロードするため、以下のHTTPハンドラーを追加します。

# 画像アップローダーのリクエストデータ
class UploadImageRequest(BaseModel):
    image_base64: str

# 画像アップローダー
@app.post("/upload_image")
async def upload_image(payload: UploadImageRequest):
    image_b64 = payload.image_base64.split(",", 1)[-1]
    image_bytes = base64.b64decode(image_b64, validate=True)

    filename = f"upload_{uuid4()}.png"

    with open(f"images/{filename}", "wb") as f:
        f.write(image_bytes)

    return {"url": f"{IMAGE_BASE_URL}/{filename}"}

また、ここで次の手順に備えてLIFFにもアクセスできるように静的ファイルをマウントしておきましょう。

app = FastAPI(lifespan=lifespan)
app.mount("/images", StaticFiles(directory="images"), name="images")
app.mount("/liff", StaticFiles(directory="liff"), name="liff")  # <- これね!

これにより、これから作るページに https://あなたのドメイン/liff/index.html でアクセスできるようになります。

続いて、手描きブラウザアプリを作ります。3年前であればここで面倒になってしまうところですが、今はAIコーディングツールがあります。バイブコーディングしていきましょう。コード例は文末に掲載しますので、ここではできあがりの画面だけ。

まずは「LIFFで〜」みたいな指示は入れず、手描きできるブラウザアプリを作るように指示します。保存先は、liffディレクトリーを作って、liff/index.htmlとします。送信ボタン押下時の処理はダミーで、base64にエンコードされたキャンバスに描画されたデータを受け取る処理(handleImageとしました)だけ作ってもらいました。

IMG_7202.jpg

さて、ここからLIFF要素を入れていきます。

まずはhead要素にスクリプトの参照を入れます。

<script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>

次に、ロード時の処理に以下の通りLIFFの初期化処理を追加。YOUR_LIFF_IDは、LINE Developersでhttps://あなたのドメイン/liff/index.htmlをLIFFとして登録して取得します。サイズはTallとしましたが、好みのものでOKです。

liff.init({ liffId: "YOUR_LIFF_ID" });

続いて、handleImageを実装します。base64の手描き画像データを先ほど作ったサーバーにアップロードして、取得したURLを画像メッセージとして送信し、LIFF画面を閉じます。

        const handleImage = async (base64) => {
            try {
                // 画像のアップロード
                const resp = await fetch("/upload_image", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ image_base64: base64 }),
                });

                if (!resp.ok) {
                    throw new Error(`upload failed with status ${resp.status}`);
                }

                // 画像メッセージの送信
                const { url } = await resp.json();
                await liff.sendMessages([
                    {
                        type: "image",
                        originalContentUrl: url,
                        previewImageUrl: url,
                    },
                ]);

                // LIFF画面を閉じる
                liff.closeWindow();
            } catch (err) {
                console.error(err);
                alert("送信に失敗しました。時間をおいて再度試してください。");
            }
        };

それでは手描き画像を送ってみましょう・・・!と言いたいところですが、このLIFFはどうやって開くの?という感じだと思います。記事を書きながら作っていてすっかり忘れていました。

リッチメニューにするとかいろいろ導線はありますが、ここでは暫定的にテキストを送ったときの応答にLIFFのURLを含めるようにします。

                TextSendMessage(
                    text=f"画像を送ってね\nhttps://liff.line.me/{your_liff_id}"
                )

LIFFを開いて、かわいい猫ちゃんの絵を描きます。

IMG_7203.jpg

すると・・・

IMG_7204.jpg

ねこ🐈→TOMATO🍅

んんんんん???

どこからどうみても猫ちゃんなのですが、なぜかトマトの絵を送ってきてくれました😂

💎 ここからの改善要素

ねこ→トマトを謎のままにしても良いですが、何と認識されたか分かった方が面白いかもしれませんね。その他含めて、こんなところが改善要素ではないでしょうか。記事を読んでやってみた方はぜひチャレンジしてみてね!

  • ユーザーが送信した絵を何と認識したかを画像メッセージと合わせて送信する
  • 履歴を持つ。今はメッセージ履歴がないので、同じ手を使ってもバレませんし、AI側もズルし放題です
  • 対話機能。おしゃべりしたいというより、🐈→🍅のようなときにNGをつきつけたりしたい
  • UIまわり。LIFF起動だけではなく、何らかの設定要素が出てきたら、それらもリッチメニューに寄せるとよいかも

・・・などなど!

それでは来年もLINE Bot開発をエンジョイしましょう!ではでは👋

🐍 Pythonコード全体

# あとで使うやつも含めて最初から定義しておこう
from contextlib import asynccontextmanager
import base64
import aiohttp
from linebot import AsyncLineBotApi, WebhookParser
from linebot.aiohttp_async_http_client import AiohttpAsyncHttpClient
from linebot.models import TextSendMessage, ImageSendMessage
from fastapi import FastAPI, Request, BackgroundTasks
from fastapi.staticfiles import StaticFiles
from openai import AsyncClient
from pydantic import BaseModel
from uuid import uuid4

# 画像取得・アップロードのためのURL。ドメインは書き換えてね
YOUR_DOMAIN = "YOUR_DOMAIN"
IMAGE_BASE_URL = f"https://{YOUR_DOMAIN}/images"

# LINE Developersで取得してね
YOUR_CHANNEL_ACCESS_TOKEN = "YOUR_CHANNEL_ACCESS_TOKEN"
YOUR_CHANNEL_SECRET = "YOUR_CHANNEL_SECRET"

# OpenAIで取得してね
OPENAI_API_KEY = "sk-proj-XXXX"

# システムプロンプト。しりとりのルールを教えてあげているよ
SYSTEM_CONTENT = """
あなたはユーザーとしりとりをしています。以下の条件に基づいて応答してください。

## 会話の条件

- ユーザーから示された画像は、ユーザーのターンの単語です。
- その画像に写っているものを認識します。画像は写真ではなくユーザーによるドローイングの場合もあります。たとえば寿司が写っていれば、ユーザーのターンの単語は「寿司」です。
- 次に、あなたのターンの単語を考えます。たとえばユーザーのターンの単語が「寿司」であれば、その読みは「スシ」であり、読みの末尾は「シ」です。そのため、あなたの単語は「シカ」や「シロネコ」になります。
- あなたのターンの単語だけを応答します。たとえば「シカ」や「シロネコ」だけを応答します。検討過程やその他の文言を入れることは許されません。
- ユーザーのターンの単語を応答することは厳禁です。たとえばコーヒーの画像を見せられて「コーヒー」と回答することは許されません。
- ユーザーから示された画像の意味がわからなくても、聞き返さずに単語を推定してください。


## 会話の例

user: スシ
assistant: シカ
user: カイ
assistant: イカ
user: カラス
assistant: スミレ
"""

# OpenAIのクライアント
openai_client = AsyncClient(api_key=OPENAI_API_KEY)

# LINE Messagin API関連のクライアントとか
session = aiohttp.ClientSession()
client = AiohttpAsyncHttpClient(session)
line_api = AsyncLineBotApi(
    channel_access_token=YOUR_CHANNEL_ACCESS_TOKEN,
    async_http_client=client
)
parser = WebhookParser(channel_secret=YOUR_CHANNEL_SECRET)


# Generate image with gpt-image-1 (You can change here to use nano banana🍌)
async def generate_image(keyword: str, image_id: str) -> str:
    image_resp = await openai_client.images.generate(
        model="gpt-image-1",
        prompt=f"{keyword}. Quick and simple drawing with a bold pen."
    )
    image_base64 = image_resp.data[0].b64_json
    image_bytes = base64.b64decode(image_base64)
    with open(f"images/{image_id}.png", "wb") as f:
        f.write(image_bytes)
    return f"{IMAGE_BASE_URL}/{image_id}.png"


# LINEからのメッセージのハンドラー
async def handle_events(events):
    for ev in events:
        if ev.message.type == "image":
            # メッセージから画像を取得
            if ev.message.content_provider.type == "line":
                # LINEアプリでアップロードした画像の取得
                image_stream = await line_api.get_message_content(ev.message.id)
                with open(f"images/{ev.message.id}_req.png", "wb") as f:
                    async for chunk in image_stream.iter_content():
                        f.write(chunk)
                        request_image_url = f"{IMAGE_BASE_URL}/{ev.message.id}_req.png"
            else:
                # LIFFで送った画像の取得
                request_image_url = ev.message.content_provider.original_content_url

            # ChatGPTに画像を送信して、AI側のターンの単語を取得
            chatgpt_resp = await openai_client.chat.completions.create(
                model="gpt-5.1",
                messages=[
                    {
                        "role": "system", "content": SYSTEM_CONTENT
                    },
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "これは私のターンの画像です。あなたのターンの単語を考えてください。"},
                            {"type": "image_url", "image_url": {"url": request_image_url}}
                        ]
                    }
                ]
            )
            assistant_word = chatgpt_resp.choices[0].message.content

            # コメントアウト。消してもいいよ
            # # まずは単純にAIターンの単語を応答
            # await line_api.reply_message(
            #     ev.reply_token,
            #     TextSendMessage(text=assistant_word)
            # )

            # 画像を生成してURLを取得
            image_url = await generate_image(keyword=assistant_word, image_id=f"{ev.message.id}_res")

            # 画像を応答
            await line_api.reply_message(
                ev.reply_token,
                ImageSendMessage(
                    original_content_url=image_url,
                    preview_image_url=image_url
                )
            )

        else:
            # 画像メッセージ以外に対しては画像を要求する応答
            await line_api.reply_message(
                ev.reply_token,
                TextSendMessage(
                    text=f"画像を送ってね\nhttps://liff.line.me/{your_liff_id}"
                )
            )


# FastAPIアプリの起動。終了時にLINEのクライアントを閉じたりする
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await session.close()

app = FastAPI(lifespan=lifespan)
app.mount("/images", StaticFiles(directory="images"), name="images")
app.mount("/liff", StaticFiles(directory="liff"), name="liff")  # <- これね!

# LINEからのWebhookの受付。さっさとokを返して、イベントの処理はバックグラウンドに流す
@app.post("/linebot")
async def handle_request(request: Request, background_tasks: BackgroundTasks):
    events = parser.parse(
        (await request.body()).decode("utf-8"),
        request.headers.get("X-Line-Signature", "")
    )
    background_tasks.add_task(handle_events, events=events)
    return "ok"

# 画像アップローダーのリクエストデータ
class UploadImageRequest(BaseModel):
    image_base64: str

# 画像アップローダー
@app.post("/upload_image")
async def upload_image(payload: UploadImageRequest):
    image_b64 = payload.image_base64.split(",", 1)[-1]
    image_bytes = base64.b64decode(image_b64, validate=True)

    filename = f"upload_{uuid4()}.png"

    with open(f"images/{filename}", "wb") as f:
        f.write(image_bytes)

    return {"url": f"{IMAGE_BASE_URL}/{filename}"}

🟨 LIFF全体

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sketch</title>
    <script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
    <style>
        :root {
            --ink: #0f172a;
            --paper: #f8fafc;
            --accent: #16a34a;
            --border: #d2d8e1;
            --card: #ffffff;
        }

        * {
            box-sizing: border-box;
        }

        body {
            margin: 0;
            font-family: "Hiragino Sans", "Noto Sans JP", "Avenir Next", "Segoe UI", sans-serif;
            color: var(--ink);
            background: radial-gradient(circle at 20% 20%, #e2f3ff, #e8edf5 40%, #e8edf5 100%);
            min-height: 100vh;
        }

        .shell {
            max-width: 960px;
            margin: 0 auto;
            padding: 32px 20px 48px;
            display: flex;
            flex-direction: column;
            gap: 24px;
        }

        header h1 {
            margin: 0 0 8px;
            font-size: 26px;
            letter-spacing: 0.02em;
        }

        header p {
            margin: 0;
            color: #52606c;
            line-height: 1.5;
        }

        .card {
            background: var(--card);
            border: 1px solid var(--border);
            border-radius: 16px;
            box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
            padding: 16px 16px 14px;
        }

        .card-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            margin-bottom: 12px;
        }

        .eyebrow {
            margin: 0;
            font-size: 14px;
            letter-spacing: 0.05em;
            color: #64748b;
            text-transform: uppercase;
        }

        .buttons {
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }

        button {
            border: 1px solid var(--border);
            background: #ffffff;
            color: var(--ink);
            border-radius: 10px;
            padding: 10px 14px;
            font-size: 14px;
            cursor: pointer;
            transition: all 140ms ease;
        }

        button:hover {
            border-color: #b1b9c7;
            box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
            transform: translateY(-1px);
        }

        button.primary {
            background: var(--accent);
            border-color: var(--accent);
            color: #f7fef9;
            font-weight: 600;
        }

        button:active {
            transform: translateY(0);
        }

        .canvas-frame {
            border: 1px dashed #cbd5e1;
            border-radius: 14px;
            padding: 12px;
            background: #ecf3fb;
        }

        canvas {
            display: block;
            width: 100%;
            max-width: 960px;
            aspect-ratio: 3 / 2;
            height: auto;
            background: var(--paper);
            border-radius: 12px;
            box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
            touch-action: none;
        }

        .note {
            font-size: 13px;
            color: #6b7280;
            margin: 8px 0 12px;
            line-height: 1.6;
        }

        @media (max-width: 640px) {
            .card-head {
                align-items: flex-start;
                flex-direction: column;
            }

            .buttons {
                width: 100%;
            }

            button {
                width: auto;
            }
        }
    </style>
</head>

<body>
    <div class="shell">
        <section class="card">
            <div class="card-head">
                <p class="eyebrow">Sketch</p>
                <div class="buttons">
                    <button id="clearBtn" type="button">リセット</button>
                    <button id="exportBtn" class="primary" type="button">送信</button>
                </div>
            </div>
            <div class="canvas-frame">
                <canvas id="sketch" width="1200" height="800" aria-label="線画キャンバス"></canvas>
            </div>
        </section>
    </div>

    <script>
        const handleImage = async (base64) => {
            try {
                const resp = await fetch("/upload_image", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ image_base64: base64 }),
                });

                if (!resp.ok) {
                    throw new Error(`upload failed with status ${resp.status}`);
                }

                const { url } = await resp.json();

                await liff.sendMessages([
                    {
                        type: "image",
                        originalContentUrl: url,
                        previewImageUrl: url,
                    },
                ]);
                liff.closeWindow();
            } catch (err) {
                console.error(err);
                alert("送信に失敗しました。時間をおいて再度試してください。");
            }
        };

        (async () => {
            try {
                await liff.init({ liffId: "YOUR_LIFF_ID" });
            } catch (err) {
                console.error("LIFF init failed", err);
                alert("LIFFの初期化に失敗しました。");
                return;
            }

            const canvas = document.getElementById('sketch');
            const ctx = canvas.getContext('2d');
            const clearBtn = document.getElementById('clearBtn');
            const exportBtn = document.getElementById('exportBtn');

            const displayWidth = 960;
            const displayHeight = 640;
            const dpr = window.devicePixelRatio || 1;

            const paintBackground = () => {
                ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
                ctx.fillStyle = '#f8fafc';
                ctx.fillRect(0, 0, displayWidth, displayHeight);
                ctx.lineWidth = 3;
                ctx.lineCap = 'round';
                ctx.lineJoin = 'round';
                ctx.strokeStyle = '#0f172a';
            };

            const setupCanvas = () => {
                canvas.width = Math.round(displayWidth * dpr);
                canvas.height = Math.round(displayHeight * dpr);
                canvas.style.width = '100%';
                canvas.style.maxWidth = displayWidth + 'px';
                canvas.style.aspectRatio = `${displayWidth} / ${displayHeight}`;
                paintBackground();
            };

            setupCanvas();

            let drawing = false;

            const pointFromEvent = (evt) => {
                const rect = canvas.getBoundingClientRect();
                const scaleX = canvas.width / rect.width / dpr;
                const scaleY = canvas.height / rect.height / dpr;
                return {
                    x: (evt.clientX - rect.left) * scaleX,
                    y: (evt.clientY - rect.top) * scaleY,
                };
            };

            canvas.addEventListener('pointerdown', (evt) => {
                evt.preventDefault();
                const { x, y } = pointFromEvent(evt);
                drawing = true;
                ctx.beginPath();
                ctx.moveTo(x, y);
            });

            canvas.addEventListener('pointermove', (evt) => {
                if (!drawing) return;
                evt.preventDefault();
                const { x, y } = pointFromEvent(evt);
                ctx.lineTo(x, y);
                ctx.stroke();
            });

            ['pointerup', 'pointerleave', 'pointercancel'].forEach((type) => {
                canvas.addEventListener(type, () => {
                    if (!drawing) return;
                    drawing = false;
                    ctx.closePath();
                });
            });

            const clearCanvas = () => {
                paintBackground();
            };

            clearBtn.addEventListener('click', clearCanvas);

            const exportBase64 = () => {
                canvas.toBlob(
                    (blob) => {
                        if (!blob) {
                            return;
                        }
                        const reader = new FileReader();
                        reader.onloadend = () => {
                            const dataUrl = reader.result || '';
                            const base64 = typeof dataUrl === 'string' ? dataUrl.split(',')[1] : '';
                            handleImage(base64);
                        };
                        reader.readAsDataURL(blob);
                    },
                    'image/png',
                    1
                );
            };

            exportBtn.addEventListener('click', exportBase64);
        })();
    </script>
</body>

</html>
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?