react-signature-canvasで手書きメモ管理アプリを作ってみた

2020.10.24

こんにちは、CX事業本部の若槻です。

最近、react-signature-canvasというReactアプリ向けに手書き文字キャンバスを実装できるパッケージを見つけました。

image

面白そうなパッケージだったので、今回はこのreact-signature-canvasを使って手書きメモ管理アプリを作ってみました。

アウトプット

次のような手書きメモを管理できるWebアプリを作ります。画面上部のキャンバスでメモを手書きして登録し、画面中央のテーブルで登録済みメモ一覧を参照できます。 手書きメモ管理アプリ

手書きメモを登録する手順は次のようになります。

  1. キャンバスに手書きでメモを描く。
  2. キャンバスに何か描かれると登録ボタンが有効になる。
  3. 登録ボタンをクリックするとキャンバスに描かれたメモがテーブルに登録される。

image

構築は次のような構成でAWS上に行います。

  • バックエンド:API Gateway + Lambda(TypeScript) + DynmoDB
  • フロントエンド:React + material-table + TypeScript + CloudFront + Amazon S3

ソースコードはGitHubに上げてあります。

やってみた

環境

% sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
% node -v
v12.14.0
% npm -v
6.13.4
% cdk --version
1.68.0 (build a6a3f46)

バックエンド作成

AWS CDKプロジェクト新規作成

% mkdir handwritten-memo-app-backend
% cd handwritten-memo-app-backend
% cdk init app --language=typescript

パッケージのインストール

$ npm install @aws-cdk/aws-apigateway@1.68.0 @aws-cdk/aws-lambda@1.68.0 @aws-cdk/aws-dynamodb@1.68.0 @aws-cdk/aws-lambda-nodejs@1.68.0 uuid4

インストールするAWS CDKライブラリのバージョンを1.68.0と指定しているのは、指定しない場合に@aws-cdk/core1.68.0がインストールされるのに対して機能ごとのパッケージは1.69.0がインストールされてしまい、両者のバージョンに齟齬が生じてエラーとなったためです。

Lambdaのコードの作成

メモの一括取得(GET)、作成(POST)、更新(PUT)の3つのメソッドに対応するLambdaを作成します。

$ mkdir lib/lambda
$ touch lib/lambda/get-item.ts lib/lambda/post-item.ts lib/lambda/update-item.ts

メモ取得APIのLambda

lib/lambda/get-item.ts

const AWS = require("aws-sdk");
const TABLE_NAME = process.env.TABLE_NAME || "";
const db = new AWS.DynamoDB.DocumentClient();

export const handler = async (): Promise<any> => {
  const params = {
    TableName: TABLE_NAME,
    ConsistentRead: true,
  };
  try {
    const response = await db.scan(params).promise();
    return {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type",
      },
      body: JSON.stringify(response.Items),
    };
  } catch (dbError) {
    return { statusCode: 500, body: JSON.stringify(dbError) };
  }
};

作成されたメモデータをすぐに取得してフロント側のテーブルに反映できるように、オプションConsistentRead: trueとして強力な整合性のある読み込みを行うようにしています。

メモ作成APIのLambda

lib/lambda/post-item.ts

const AWS = require("aws-sdk");
const TABLE_NAME = process.env.TABLE_NAME || "";
const db = new AWS.DynamoDB.DocumentClient();
const uuid4 = require("uuid4");

export const handler = async (event: any = {}): Promise<any> => {
  var body = JSON.parse(event.body);
  const formatDate = (date: Date | any, format: string) => {
    format = format.replace(/yyyy/g, date.getFullYear());
    format = format.replace(/MM/g, ("0" + (date.getMonth() + 1)).slice(-2));
    format = format.replace(/dd/g, ("0" + date.getDate()).slice(-2));
    format = format.replace(/HH/g, ("0" + date.getHours()).slice(-2));
    format = format.replace(/mm/g, ("0" + date.getMinutes()).slice(-2));
    format = format.replace(/ss/g, ("0" + date.getSeconds()).slice(-2));
    return format;
  };
  const params = {
    TableName: TABLE_NAME,
    Item: {
      imageData: body.imageData,
      createdAt: formatDate(
        new Date(
          Date.now() + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000
        ),
        "yyyy/MM/ddTHH:mm:ss"
      ),
      memoId: uuid4(),
    },
  };
  try {
    await db.put(params).promise();
    return {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type",
      },
    };
  } catch (dbError) {
    return { statusCode: 500, body: JSON.stringify(dbError) };
  }
};

メモ更新APIのLambda

lib/lambda/update-item.ts

const AWS = require("aws-sdk");
const TABLE_NAME = process.env.TABLE_NAME || "";
const db = new AWS.DynamoDB.DocumentClient();

export const handler = async (event: any = {}): Promise<any> => {
  const formatDate = (date: Date | any, format: string) => {
    format = format.replace(/yyyy/g, date.getFullYear());
    format = format.replace(/MM/g, ("0" + (date.getMonth() + 1)).slice(-2));
    format = format.replace(/dd/g, ("0" + date.getDate()).slice(-2));
    format = format.replace(/HH/g, ("0" + date.getHours()).slice(-2));
    format = format.replace(/mm/g, ("0" + date.getMinutes()).slice(-2));
    format = format.replace(/ss/g, ("0" + date.getSeconds()).slice(-2));
    return format;
  };
  const key = {
    memoId: "",
  };
  if (event.pathParameters && event.pathParameters.memoId) {
    key.memoId = event.pathParameters.memoId;
  }
  const params = {
    TableName: TABLE_NAME,
    Key: key,
    UpdateExpression: "set #status = :status, doneAt = :doneAt",
    ExpressionAttributeNames: {
      "#status": "status",
    },
    ExpressionAttributeValues: {
      ":status": "completed",
      ":doneAt": formatDate(
        new Date(
          Date.now() + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000
        ),
        "yyyy/MM/ddTHH:mm:ss"
      ),
    },
  };
  try {
    await db.update(params).promise();
    return {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type",
      },
    };
  } catch (dbError) {
    return { statusCode: 500, body: JSON.stringify(dbError) };
  }
};

メモの更新はステータスstatusを完了completedに変更する処理のみ行うようにしています。

AWS CDKのデプロイ用コードの作成

デプロイ用のコードを書きます。

lib/handwritten-memo-app-backend-stack.ts

import * as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { Runtime } from "@aws-cdk/aws-lambda";
import {
  RestApi,
  LambdaIntegration,
  IResource,
  MockIntegration,
  PassthroughBehavior,
} from "@aws-cdk/aws-apigateway";
import { NodejsFunction } from "@aws-cdk/aws-lambda-nodejs";

export class HandwrittenMemoAppBackendStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const memoTable = new Table(this, "items", {
      partitionKey: {
        name: "memoId",
        type: AttributeType.STRING,
      },
      tableName: "memo_table",
    });

    const getItemLambda = new NodejsFunction(this, "getItemsFunction", {
      entry: "lib/lambda/get-item.ts",
      runtime: Runtime.NODEJS_12_X,
      environment: {
        TABLE_NAME: memoTable.tableName,
      },
    });
    memoTable.grantReadData(getItemLambda);

    const postItemLambda = new NodejsFunction(this, "postItemsFunction", {
      entry: "lib/lambda/post-item.ts",
      runtime: Runtime.NODEJS_12_X,
      environment: {
        TABLE_NAME: memoTable.tableName,
      },
    });
    memoTable.grantReadWriteData(postItemLambda);

    const updateItemLambda = new NodejsFunction(this, "completeItemsFunction", {
      entry: "lib/lambda/update-item.ts",
      runtime: Runtime.NODEJS_12_X,
      environment: {
        TABLE_NAME: memoTable.tableName,
      },
    });
    memoTable.grantReadWriteData(updateItemLambda);

    const api = new RestApi(this, "itemsApi", {
      restApiName: "Items Service",
    });

    const items = api.root.addResource("items");
    const getItemIntegration = new LambdaIntegration(getItemLambda);
    items.addMethod("GET", getItemIntegration);
    addCorsOptions(items);

    const item = api.root.addResource("item");
    const postItemIntegration = new LambdaIntegration(postItemLambda);
    item.addMethod("POST", postItemIntegration);
    addCorsOptions(item);

    const memoId = item.addResource("{memoId}");
    const updateItemIntegration = new LambdaIntegration(updateItemLambda);
    memoId.addMethod("PUT", updateItemIntegration);
    addCorsOptions(memoId);
  }
}

export function addCorsOptions(apiResource: IResource) {
  apiResource.addMethod(
    "OPTIONS",
    new MockIntegration({
      integrationResponses: [
        {
          statusCode: "200",
          responseParameters: {
            "method.response.header.Access-Control-Allow-Headers":
              "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'",
            "method.response.header.Access-Control-Allow-Origin": "'*'",
            "method.response.header.Access-Control-Allow-Credentials":
              "'false'",
            "method.response.header.Access-Control-Allow-Methods":
              "'OPTIONS,GET,PUT,POST,DELETE'",
          },
        },
      ],
      passthroughBehavior: PassthroughBehavior.NEVER,
      requestTemplates: {
        "application/json": '{"statusCode": 200}',
      },
    }),
    {
      methodResponses: [
        {
          statusCode: "200",
          responseParameters: {
            "method.response.header.Access-Control-Allow-Headers": true,
            "method.response.header.Access-Control-Allow-Methods": true,
            "method.response.header.Access-Control-Allow-Credentials": true,
            "method.response.header.Access-Control-Allow-Origin": true,
          },
        },
      ],
    }
  );
}

const app = new cdk.App();
new HandwrittenMemoAppBackendStack(app, "HandwrittenMemoAppBackendStack");
app.synth();

Lambdaの定義で@aws-cdk/aws-lambda-nodejsを使用することにより、cdk deploy実行時にTypeScriptのコードのトランスパイルとバンドルを自動で行えるようにしています。

AWS CDKでのデプロイ

% cdk deploy

デプロイに成功すると、作成されたAPIエンドポイントhttps://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/が次のように表示されるので控えておきます。

 ✅  HandwrittenMemoAppBackendStack

Outputs:
HandwrittenMemoAppBackendStack.itemsApiEndpointXXXXXXX = https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/

これでバックエンドの作成ができました。

フロントエンド作成

AWS CDKプロジェクト新規作成

% mkdir handwritten-memo-app-frontend
% cd handwritten-memo-app-frontend
% cdk init app --language=typescript

AWS CDK用のパッケージインストール

% npm install @aws-cdk/aws-cloudfront@1.68.0 @aws-cdk/aws-s3@1.68.0 @aws-cdk/aws-s3-deployment@1.68.0
% npm install --save-dev dotenv-cli

ここでdotenv-cliをインストールしてReactアプリがAPIエンドポイントの情報を環境変数として読み込めるようにしています。

AWS CDKのデプロイ用コードの作成

デプロイ用のコードを書きます。

lib/handwritten-memo-app-frontend-stack.ts

import * as cdk from "@aws-cdk/core";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as s3 from "@aws-cdk/aws-s3";
import * as s3deploy from "@aws-cdk/aws-s3-deployment";
import * as iam from "@aws-cdk/aws-iam";

export class HandwrittenMemoAppFrontendStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      websiteErrorDocument: "index.html",
      websiteIndexDocument: "index.html",
    });

    const websiteIdentity = new cloudfront.OriginAccessIdentity(
      this,
      "WebsiteIdentity"
    );

    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [websiteIdentity.grantPrincipal],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    const websiteDistribution = new cloudfront.CloudFrontWebDistribution(
      this,
      "WebsiteDistribution",
      {
        errorConfigurations: [
          {
            errorCachingMinTtl: 300,
            errorCode: 403,
            responseCode: 200,
            responsePagePath: "/index.html",
          },
          {
            errorCachingMinTtl: 300,
            errorCode: 404,
            responseCode: 200,
            responsePagePath: "/index.html",
          },
        ],
        originConfigs: [
          {
            s3OriginSource: {
              s3BucketSource: websiteBucket,
              originAccessIdentity: websiteIdentity,
            },
            behaviors: [
              {
                isDefaultBehavior: true,
              },
            ],
          },
        ],
        priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
      }
    );

    new s3deploy.BucketDeployment(this, "WebsiteDeploy", {
      sources: [s3deploy.Source.asset("./web/build")],
      destinationBucket: websiteBucket,
      distribution: websiteDistribution,
      distributionPaths: ["/*"],
    });
  }
}

Reactアプリ新規作成

% npx create-react-app web --typescript

Reactアプリ用のパッケージインストール

% npm --prefix web install react-dom @types/react-dom react-signature-canvas @types/react-signature-canvas @material-ui/core material-table axios
% npm --prefix web install --save-dev @types/react

TypeScriptでreact-signature-canvasを使用する場合は型定義として@types/react-signature-canvasも合わせてインストールします。

.envの作成

$ touch web/.env

.envファイルに環境変数を記載してdotenvで読み込めるようにします。API_ENDPOINT_MEMOSは先程バックエンドのデプロイ時に控えたAPIエンドポイントのURLを指定します。

web/.env

SKIP_PREFLIGHT_CHECK=true
API_ENDPOINT_MEMOS=https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/

ここでSKIP_PREFLIGHT_CHECK=trueはルートとアプリの両ディレクトリにインストールされたライブラリのバージョンに差異がある場合に競合エラーを回避するためのフラグとして指定しています。

buildスクリプトの更新

web/package.jsonのbuildスクリプトを更新して、ビルド時にdotenv.envから環境変数を取り込むようにします。

web/package.json

{
  //〜〜〜
  "scripts": {
    "start": "react-scripts start",
    "build": "dotenv -e .env react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
}

フォントを導入

web/public/index.html<head>タグ内ににフォントのCDNのURLを追加します。Reactでmaterial-table(Material-UI)を使用する際は追加するようにしましょう。

web/public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP&subset=japanese" />
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />

Webアプリの枠組みのテンプレートとなるコンポーネントを作成

% touch web/src/GenericTemplate.tsx

web/src/GenericTemplate.tsx

import React from "react";
import clsx from "clsx";
import { createMuiTheme } from "@material-ui/core/styles";
import * as colors from "@material-ui/core/colors";
import { makeStyles, createStyles, Theme } from "@material-ui/core/styles";
import { ThemeProvider } from "@material-ui/styles";
import CssBaseline from "@material-ui/core/CssBaseline";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import Container from "@material-ui/core/Container";

const theme = createMuiTheme({
  typography: {
    fontFamily: [
      "Noto Sans JP",
      "Lato",
      "游ゴシック Medium",
      "游ゴシック体",
      "Yu Gothic Medium",
      "YuGothic",
      "ヒラギノ角ゴ ProN",
      "Hiragino Kaku Gothic ProN",
      "メイリオ",
      "Meiryo",
      "MS Pゴシック",
      "MS PGothic",
      "sans-serif",
    ].join(","),
  },
  palette: {
    primary: { main: colors.blue[800] },
  },
});

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      display: "flex",
    },
    toolbar: {
      paddingRight: 24,
    },
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
      transition: theme.transitions.create(["width", "margin"], {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.leavingScreen,
      }),
    },
    title: {
      flexGrow: 1,
    },
    pageTitle: {
      marginBottom: theme.spacing(1),
    },
    appBarSpacer: theme.mixins.toolbar,
    content: {
      flexGrow: 1,
      height: "100vh",
      overflow: "auto",
    },
    container: {
      paddingTop: theme.spacing(4),
      paddingBottom: theme.spacing(4),
    },
    paper: {
      padding: theme.spacing(2),
      display: "flex",
      overflow: "auto",
      flexDirection: "column",
    },
    link: {
      textDecoration: "none",
      color: theme.palette.text.secondary,
    },
  })
);

export interface GenericTemplateProps {
  children: React.ReactNode;
  title: string;
}

const GenericTemplate: React.FC<GenericTemplateProps> = ({
  children,
  title,
}) => {
  const classes = useStyles();

  return (
    <ThemeProvider theme={theme}>
      <div className={classes.root}>
        <CssBaseline />
        <AppBar position="absolute" className={clsx(classes.appBar)}>
          <Toolbar className={classes.toolbar}>
            <Typography
              component="h1"
              variant="h6"
              color="inherit"
              noWrap
              className={classes.title}
            >
              手書きメモ
            </Typography>
          </Toolbar>
        </AppBar>
        <main className={classes.content}>
          <div className={classes.appBarSpacer} />
          <Container maxWidth="lg" className={classes.container}>
            <Typography
              component="h2"
              variant="h5"
              color="inherit"
              noWrap
              className={classes.pageTitle}
            >
              {title}
            </Typography>
            {children}
          </Container>
        </main>
      </div>
    </ThemeProvider>
  );
};

export default GenericTemplate;

Webアプリのコンテンツ(手書きキャンバス、テーブル)となるコンポーネントを作成

% touch web/src/MemoPage.tsx

web/src/MemoPage.tsx

import React, { useRef, useState, useCallback, useEffect } from "react";
import GenericTemplate from "./GenericTemplate";
import MaterialTable from "material-table";
import SignatureCanvas from "react-signature-canvas";
import ReactSignatureCanvas from "react-signature-canvas";
import pointGroupArray from "react-signature-canvas";
import Axios from "axios";
import Paper from "@material-ui/core/Paper";
import { makeStyles } from "@material-ui/core/styles";
import Button from "@material-ui/core/Button";

const API_ENDPOINT_MEMOS = process.env.REACT_APP_API_ENDPOINT_MEMOS!;

export interface Memo {
  imageId: string;
  createdAt: string;
  imageData: string;
  status: string;
  doneAt: string;
}

const useStyles = makeStyles((theme) => ({
  paper: {
    padding: theme.spacing(2),
    marginBottom: theme.spacing(2),
    display: "flex",
    alignItems: "center",
  },
  button: {
    marginLeft: theme.spacing(10),
    height: 60,
  },
}));

const MemoPage: React.FC = () => {
  const classes = useStyles();
  const canvasRef = useRef<ReactSignatureCanvas | null>();
  const [image, setImage] = useState<string>();

  const useGetMemoList = () => {
    const [isCompleted, setIsCompleted] = useState(false);
    const [data, setData] = useState<Memo[]>([]);

    const getData = useCallback(async () => {
      setIsCompleted(false);
      const response = await Axios.get(API_ENDPOINT_MEMOS + "items");
      setData(response.data);
      setIsCompleted(true);
    }, []);
    return { getData, data, isCompleted };
  };

  const getMemoList = useGetMemoList();

  const createTableData = () => {
    const data = getMemoList.data.map((d) =>
      Object.assign({}, d, {
        imageData: <img src={`${d.imageData}`} alt="text" />,
      })
    );
    return data;
  };

  const createMemo = async () => {
    await Axios.post(
      API_ENDPOINT_MEMOS + "item",
      { imageData: image },
      {
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
  };

  const updateMemo = async (data: Memo) => {
    await Axios.put(API_ENDPOINT_MEMOS + "item/" + (data as any).memoId, {
      headers: {
        "Content-Type": "application/json",
      },
    });
  };

  useEffect(() => {
    if (!getMemoList.isCompleted) {
      getMemoList.getData();
    }
  }, [getMemoList]);

  return (
    <GenericTemplate title="">
      <Paper className={classes.paper}>
        <SignatureCanvas
          ref={(ref) => {
            canvasRef.current = ref;
          }}
          minWidth={2}
          maxWidth={2}
          penColor="white"
          backgroundColor="black"
          canvasProps={{
            width: 400,
            height: 80,
            className: "sigCanvas",
          }}
          onEnd={() => {
            setImage((canvasRef.current as pointGroupArray).toDataURL());
          }}
        />
        <Button
          className={classes.button}
          variant="contained"
          color="primary"
          disabled={image === undefined}
          onClick={() => {
            createMemo();
            setImage(undefined);
            (canvasRef.current as pointGroupArray).clear();
            getMemoList.getData();
          }}
        >
          登録
        </Button>
        <Button
          className={classes.button}
          variant="contained"
          color="primary"
          disabled={image === undefined}
          onClick={() => {
            setImage(undefined);
            (canvasRef.current as pointGroupArray).clear();
          }}
        >
          クリア
        </Button>
      </Paper>
      <MaterialTable
        columns={[
          { title: "メモ画像", field: "imageData" },
          { title: "登録日", field: "createdAt", defaultSort: "desc" },
          { title: "ステータス", field: "status" },
          { title: "完了日", field: "doneAt" },
        ]}
        data={createTableData()}
        options={{
          search: false,
          toolbar: false,
        }}
        localization={{
          header: { actions: "" },
        }}
        actions={[
          {
            icon: () => (
              <Button variant="contained" color="primary">
                完了
              </Button>
            ),
            onClick: (_, data) => {
              updateMemo(data as any);
              getMemoList.getData();
            },
          },
        ]}
      />
    </GenericTemplate>
  );
};

export default MemoPage;

手書きキャンバスの実装

メモを手書きするキャンバスはSignatureCanvasコンポーネントとして実装し、onEndプロパティを使用してキャンバス上での手書きストロークが終了するたびに、キャンバスの画像データをBase64形式で取得するようにしています。

        <SignatureCanvas
          ref={(ref) => {
            canvasRef.current = ref;
          }}
          minWidth={2}
          maxWidth={2}
          penColor="white"
          backgroundColor="black"
          canvasProps={{
            width: 400,
            height: 80,
            className: "sigCanvas",
          }}
          onEnd={() => {
            setImage((canvasRef.current as pointGroupArray).toDataURL());
          }}
        />

登録処理の実装

キャンバス右横に配置した登録ボタンをクリックすると、キャンパスに描かれた手書きメモがcreateMemo()によりテーブルに登録されます。またキャンバスがclear()によりクリアされます。

        <Button
          className={classes.button}
          variant="contained"
          color="primary"
          disabled={image === undefined}
          onClick={() => {
            createMemo();
            setImage(undefined);
            (canvasRef.current as pointGroupArray).clear();
            getMemoList.getData();
          }}
        >
          登録
        </Button>

登録ボタンをクリック時の様子 手書きメモ管理アプリ

クリア処理の実装

キャンバス右横に配置したクリアボタンをクリックすると、キャンバスがclear()によりクリアされます。

        <Button
          className={classes.button}
          variant="contained"
          color="primary"
          disabled={image === undefined}
          onClick={() => {
            setImage(undefined);
            (canvasRef.current as pointGroupArray).clear();
          }}
        >
          クリア
        </Button>

クリアボタンをクリック時の様子 手書きメモ管理アプリ_クリア

完了処理の実装

MaterialTableコンポーネントでは、actionsプロパティを使用して、メモごとの完了ボタンをクリックするとupdateMemo()により更新APIへのリクエストが行われ該当のメモのステータスを完了completedに変更するようにしています。

        actions={[
          {
            icon: () => (
              <Button variant="contained" color="primary">
                完了
              </Button>
            ),
            onClick: (_, data) => {
              updateMemo(data as any);
              getMemoList.getData();
            },
          },
        ]}

完了ボタンをクリック時の様子 手書きメモ管理アプリ_完了

App.tsxの更新

web/src/App.tsxファイルを次の内容で更新します。

import React from 'react';
import './App.css';

import MemoPage from "./MemoPage";

const App: React.FC = () => {
  return (
        <MemoPage/>
  );
}

export default App;

AWS CDKでのデプロイ

tsconfig.jsonexcludeにReactアプリのディレクトリを追加します。

tsconfig.json

{
  //
  "exclude": ["cdk.out", "web"]
}

Reactアプリのビルドとデプロイを実行します。

% npm --prefix web run build
% cdk deploy

デプロイが成功したらCloudFrontコンソールでWebアプリのアクセスURLxxxxxxxxx.cloudfront.netを確認します。 image

xxxxxxxxx.cloudfront.netでアクセスできました。 image

おわりに

react-signature-canvasを使って手書きメモ管理アプリを作ってみました。

Webアプリなのでタブレットでもスマートフォンでも使えますし、日常の買い物用の手書きメモなどの用途に使えそうだなと思います。

参考

以上