プリザンターで手書きサインを入力できるようにする

## 概要
店舗での決済や現場での作業報告などでタブレット端末を利用した手書きの電子サインが用いられることがあります。この手書きサインをプリザンターのスクリプトやスタイルの機能を用いて実装する方法をまとめました。スクリプトが長いので、先に結論から書くとプリザンターで以下のイメージのような手書きの入力ができるようになります。

![Animation002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37295/66ec9aa3-ed9f-70ce-d3d6-01a6d9580736.gif)

:::note info
動作を保証するものではありませんが、以下の環境で動作することを確認しています。
①Windows11＋Chrome
②iPad（第6世代）＋Chrome
:::

## 適用手順
### 1. エディタ項目の有効化

テーブルの管理画面から以下の手順でエディタの項目を有効化します。

1. 対象テーブルの管理画面を開きます。  
2. エディタタブより対象の説明項目（説明Aなど）を有効化します。  
3. 対象の説明項目の詳細設定より表示名を「手書きサイン」に変更します。  
4. 対象の説明項目の詳細設定より「読取専用」のチェックをオンにします。  
5. 画面下部の更新ボタンをクリックし、変更が反映されたことを確認します。  

https://pleasanter.org/manual/table-management-editor-columns

### 2. スクリプトの設定

テーブルの管理画面から以下の手順でスクリプトを設定します。

1. 対象テーブルの管理画面を開きます。  
2. スクリプトタブより新規作成ボタンをクリックします。  
3. タイトル欄に任意の名称を入力します。  
4. スクリプト欄に以下の内容を貼り付けます。  
5. 出力先の「全て」のチェックをオフにし、「新規作成」「編集」のチェックのみをオンにします。  
6. 画面下部の更新ボタンをクリックし、変更が反映されたことを確認します。  

※残りのスクリプトについても同様の手順で設定します。

https://pleasanter.org/manual/table-management-script

```JavaScript:スクリプトのタイトル（任意）：手書き入力機能
// 入力欄の変更フラグ
var canvasChanged = false;
// サイン済みチェック
function checkSigned(columnId) {
    return ($('#' + columnId).text().length !== 0);
}
// 入力要素の追加
function addInputElement(columnId) {
    $('#' + columnId + 'Field > p > div.handwritten').remove();
    var html = '';
    html += '<div class="handwritten">';
    html += '<button id="input-button" type="button" class="button button-icon input-button" data-icon="ui-icon-pencil" onclick="inputButtonClick()">入力</button>';
    html += '<div class="modal-container">';
    html += '<div class="modal-body">';
    html += '<div class="modal-content">';
    html += '<p>サイン入力欄</p><br>';
    html += '<p><canvas id="drawCanvasId" class="canvas" width="870px" height="240px"></canvas><br></p><br>';
    html += '<div class="modal-button">';
    html += '<button type="button" id="submitButtonId" onclick="submitButtonClick()">ＯＫ</button>';
    html += '<button type="button" id="clearButtonId"">クリア</button><br>';
    html += '</div>';
    html += '</div>';
    html += '</div>';
    html += '</div>';
    html += '</div>';
    $('#' + columnId + 'Field > p').append(html);
}
// 入力ボタンをクリックしたらモーダルを表示する
function inputButtonClick() {
    $('.modal-container').addClass('active');
    $('#MainCommandsContainer').hide();
};
// ＯＫボタンをクリックしたらモーダルを閉じる
function submitButtonClick() {
    $('.modal-container').removeClass('active');
    $('#MainCommandsContainer').show();
};
// 手書き入力欄の設定
function Handwritten(columnId) {
    'use strict';
    this.drawCanvasId = 'drawCanvasId';
    this.clearButtonId = 'clearButtonId';
    this.submitButtonId = 'submitButtonId';
    this.isMouseDown = false;
    // マウス、タップの座標
    this.position = [];
    this.position.x = 0;
    this.position.y = 0;
    this.position.px = 0;
    this.position.py = 0;
    // 横比率、縦比率
    this.rate = []; this.rate.x = 0; this.rate.y = 0;
    this.can = null;
    this.ctx = null;
    this.clearButton = null;
    window.addEventListener(
        'DOMContentLoaded', function () {
            this.can = document.getElementById(this.drawCanvasId);
            // イベントの設定
            this.can.addEventListener('touchstart', this.onDown.bind(this), { passive: false });
            this.can.addEventListener('touchmove', this.onMove.bind(this), { passive: false });
            this.can.addEventListener('touchend', this.onUp.bind(this));
            this.can.addEventListener('mousedown', this.onMouseDown.bind(this));
            this.can.addEventListener('mousemove', this.onMouseMove.bind(this));
            this.can.addEventListener('mouseup', this.onMouseUp.bind(this));
            window.addEventListener('mousemove', this.stopShake.bind(this));
            this.ctx = this.can.getContext('2d');
            // クリアボタン押下時
            if (document.getElementById(this.clearButtonId)) {
                clickClearButton(this);
            }
            // ＯＫボタン押下時
            if (document.getElementById(this.submitButtonId)) {
                clickSubmitButton(this, columnId);
            }
            // スタイルシートの登録
            let style = document.createElement('style');
            document.head.appendChild(style);
            style.sheet.insertRule('canvas#' + this.drawCanvasId + '{-ms-touch-action:none;touch-action:none;}', 0);
            let s = window.getComputedStyle(this.can);
            //canvas.widthとcanvas.style.widthの比率を取得する
            this.rate.x = parseInt(this.can.width) / parseInt(s.width);
            //canvas.heightとcanvas.style.heightの比率を取得する
            this.rate.y = parseInt(this.can.height) / parseInt(s.height);
        }.bind(this)
    );
    // TouchStart
    this.onDown = function (event) {
        canvasChanged = true;
        this.isMouseDown = true;
        this.position.px = event.touches[0].pageX - event.target.getBoundingClientRect().left - this.getScrollPosition().x;
        this.position.py = event.touches[0].pageY - event.target.getBoundingClientRect().top - this.getScrollPosition().y;
        this.position.x = this.position.px;
        this.position.y = this.position.py;
        this.drawLine();
        event.preventDefault();
        event.stopPropagation();
    }
    // TouchMove
    this.onMove = function (event) {
        if (this.isMouseDown) {
            this.position.x = event.touches[0].pageX - event.target.getBoundingClientRect().left - this.getScrollPosition().x;
            this.position.y = event.touches[0].pageY - event.target.getBoundingClientRect().top - this.getScrollPosition().y;
            this.drawLine();
            this.position.px = this.position.x;
            this.position.py = this.position.y;
            event.stopPropagation();
        };
    }
    // TouchEnd
    this.onUp = function (event) {
        this.isMouseDown = false;
        event.stopPropagation();
    }
    // MouseDown
    this.onMouseDown = function (event) {
        canvasChanged = true;
        this.position.px = event.clientX - event.target.getBoundingClientRect().left;
        this.position.py = event.clientY - event.target.getBoundingClientRect().top;
        this.position.x = this.position.px;
        this.position.y = this.position.py;
        this.drawLine();
        this.isMouseDown = true;
        event.stopPropagation();
    }
    // MouseMove
    this.onMouseMove = function (event) {
        if (this.isMouseDown) {
            this.position.x = event.clientX - event.target.getBoundingClientRect().left;
            this.position.y = event.clientY - event.target.getBoundingClientRect().top;
            this.drawLine();
            this.position.px = this.position.x;
            this.position.py = this.position.y;
            event.stopPropagation();
        }
    }
    // MouseUp
    this.onMouseUp = function (event) {
        this.isMouseDown = false;
        event.stopPropagation();
    }
    this.stopShake = function (event) {
        this.isMouseDown = false;
        event.stopPropagation();
    }
    this.drawLine = function () {
        this.ctx.strokeStyle = '#000000';    // 線の色
        this.ctx.lineWidth = 5;    // 線の太さ
        this.ctx.lineJoin = 'round';
        this.ctx.lineCap = 'round';
        this.ctx.beginPath();
        this.ctx.moveTo(this.position.px * this.rate.x, this.position.py * this.rate.y);
        this.ctx.lineTo(this.position.x * this.rate.x, this.position.y * this.rate.y);
        this.ctx.stroke();
    }
    // スクロール位置の取得
    this.getScrollPosition = function () {
        return {
            'x': document.documentElement.scrollLeft || document.body.scrollLeft,
            'y': document.documentElement.scrollTop || document.body.scrollTop
        };
    }
    // 入力欄のクリア
    function clickClearButton(obj) {
        obj.clearButton = document.getElementById(obj.clearButtonId);
        obj.clearButton.addEventListener(
            'click', function () {
                canvasChanged = false;
                obj.ctx.clearRect(
                    0,
                    0,
                    obj.can.getBoundingClientRect().width * obj.rate.x,
                    obj.can.getBoundingClientRect().height * obj.rate.y
                );
            }.bind(obj)
        );
    }
    // 入力内容の反映（画像登録）
    function clickSubmitButton(obj, columnId) {
        obj.submitButton = document.getElementById(obj.submitButtonId);
        obj.submitButton.addEventListener(
            'click', function () {
                if (canvasChanged === true) {
                    obj.can.toBlob(
                        function (blob) {
                            const imageFile = new File(
                                [blob],
                                'image.png', {
                                type: 'image/png',
                                lastModified: Date.now()
                            }
                            );
                            $p.set($('#' + columnId), '');
                            $p.uploadImage(columnId, imageFile);
                        },
                        'image/png',
                        1
                    );
                };
            }.bind(obj)
        );
    }
}
```

```JavaScript:スクリプトのタイトル（任意）：新規／編集画面の読込時の実行用スクリプト
$p.events.on_editor_load = function () {
    var columnId = 'Results_DescriptionA'
    // サイン済みチェック
    if (!checkSigned(columnId)) {
        // 入力要素の追加
        addInputElement(columnId);
        // 手書き入力欄の設定
        new Handwritten(columnId);
    }
};
```

:::note warn
columnIdは利用する項目に合わせて変更してください。
（説明BならResults_DescriptionB）
:::

### 3. スタイルの設定

テーブルの管理画面から以下の手順でスタイルを設定します。

1. 対象テーブルの管理画面を開きます。
2. スタイルタブより新規作成ボタンをクリックします。
3. タイトル欄に任意の名称を入力します。
4. スタイル欄に以下の内容を貼り付けます。
5. 出力先の「全て」のチェックをオフにし、「新規作成」「編集」のチェックのみをオンにします。
6. 画面下部の更新ボタンをクリックし、変更が反映されたことを確認します。

https://pleasanter.org/manual/table-management-style

```css:スタイルのタイトル（任意）：手書き入力機能
/*モーダル本体の指定 + モーダル外側の背景の指定*/
.modal-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    text-align: center;
    background: rgba(0,0,0,50%);
    overflow: auto;
    opacity: 0;
    visibility: hidden;
    transition: .3s;
    box-sizing: border-box;
    z-index: 12;
}
    /*モーダル本体の擬似要素の指定*/
    .modal-container:before {
        content: "";
        display: inline-block;
        vertical-align: middle;
        height: 100%;
    }
    /*モーダル本体に「active」クラス付与した時のスタイル*/
    .modal-container.active {
        opacity: 1;
        visibility: visible;
    }
/*モーダル枠の指定*/
.modal-body {
    position: relative;
    display: inline-block;
    vertical-align: middle;
    z-index: 13;
    width: 100%;
    height: 100%;
}
/*モーダル内のコンテンツの指定*/
.modal-content {
    background: #fff;
    text-align: center;
    padding: 30px;
    width: 100%;
    height: 100%;
}
/*手書き入力欄のスタイル*/
.canvas {
    border: 1px solid #000;
    background: #fff;
}
/*モーダルボタンのスタイル*/
.modal-button > button {
    width: 200px;
    height: 100px;
    font-size: 20px;
    color: #000000;
}
/*モーダルボタン間隔の指定*/
.modal-button button:first-of-type {
    margin-right: 100px;
}
```

## 補足
以下のサイトで紹介されているソースコードを参考に、プリザンターで画像登録に必要な処理を組み合わせて作成しました。

https://mam-mam.net/javascript/draw_js.html  

モーダルウィンドウの実装については以下のサイトを参考にしました。

https://recooord.org/jquery-modal-window/  

画像登録の処理にはプリザンターのスクリプト関数である$p.uploadImageを利用しています。
一般ユーザ向けでない内部処理用の関数のため、ユーザマニュアルはありません。
カスタマイズなどのために詳しく理解したい方は、以下のソースコードを参照してください。

https://github.com/Implem/Implem.Pleasanter/blob/a9f3c772341b443907433d5aedaf6f0840be908f/Implem.Pleasanter/wwwroot/scripts/markdown.js#L144

https://github.com/Implem/Implem.Pleasanter/blob/main/Implem.Pleasanter/wwwroot/scripts/markdownevents.js

$p.uploadImageを使うには登録する画像をBlob(File)に変換する必要があるため、以下のサイトを参考に関数clickSubmitButtonを作成しました。

https://lab.syncer.jp/Web/JavaScript/Canvas/7/  

## おわりに
たとえば訪問先で紙の作業報告書にサインを書いてもらう業務があったとして、タブレット端末からプリザンターの画面で手書きのサインをもらうことで、管理がシンプルになると同時にペーパーレス化も進められそうですね。iPadでも動くはずなので、よかったら試してみてください。

## 参考リンク
[- 手書きやマウスのドラッグで絵や文字が書けるWEBサイト](https://mam-mam.net/javascript/draw_js.html)
[- 【jQuery】シンプルなモーダルウィンドウの実装方法 | Recooord | Web制作で扱うコーディングスニペットを紹介](https://recooord.org/jquery-modal-window/)
[- Canvasを画像に変換する方法](https://lab.syncer.jp/Web/JavaScript/Canvas/7/)
[- テーブルの管理：エディタ：エディタの項目の設定 | Pleasanter](https://pleasanter.org/manual/table-management-editor-columns)
[- テーブルの管理：スクリプト | Pleasanter](https://pleasanter.org/manual/table-management-script)
[- 開発者向け機能：スクリプト：$p.events.on_editor_load | Pleasanter](https://pleasanter.org/manual/script-events-on-editor-load)
[- テーブルの管理：スタイル | Pleasanter](https://pleasanter.org/manual/table-management-style)