手書きパッドの組み込み

ここでは、RayPenをLightning Webコンポーネントに配置し、ユーザーによる手書きと手書き結果を表示する手順を説明します。ユースケースとして病院において手術を実施する前に患者の同意を得るシーンを想定しています。紙の契約書への署名の手続きの代わりに、Salesforce上の契約書と手書きの署名を行うサンプルです。なお、ここで説明する内容はサンプルパッケージでインストールすることもできます。

このページでの手順は、Salesforce CLIやVisual Studio CodeなどLightning Webコンポーネントの開発環境が整っており、組織を操作するSalesforce DXプロジェクトでLightning WebコンポーネントやApexクラスなどを作成およびデプロイすることを前提としています。

1. 事前準備

このサンプルでは最後に手書き結果画像を利用してPDFを作成します。PDFの作成にはpdf-libを利用します。以下の2つを組織の静的リソースに格納します。それぞれのリンクからダウンロードしてください。

2. 手書き結果を格納するカスタムオブジェクトを作成する

まず、手書き結果を保存するためにカスタムオブジェクトを作成します。

  1. Salesforce Classicの場合、「設定 > 作成 > オブジェクト」をクリックする。Lightning Experienceの場合、歯車アイコンをクリックした後「設定 > オブジェクトおよび項目 > オブジェクトマネージャー」をクリックする。
  2. Salesforce Classicの場合は「新規カスタムオブジェクト」ボタン、Lightning Experienceの場合は「作成 > カスタムオブジェクト」をクリックする。
  3. カスタムオブジェクトの情報に次の値を設定し、「保存」をクリックする。
    • 表示ラベル: SurgeryAgreement
    • オブジェクト名: Surgery_Agreement
    • レコード名: Surgery No.
    • データ型: 自動採番
    • 表示形式: SGRYAG-{00000000}
    • 開始番号: 0

作成した「SurgeryAgreement」カスタムオブジェクトに、次のカスタム項目を作成します。

項目の表示ラベル API参照名 データ型 説明
AgreementText AgreementText__c ロングテキスト(131072) 契約の文章
Date Date__c テキストエリア(255) 署名した日付
DoctorTitle DoctorTitle__c テキストエリア(255) 医師名
PatientName PatientName__c テキストエリア(255) 患者名
Patient Signature Patient_Signature__c Lookup(Signature) 署名のルックアップ
PDFPageUrl PDFPageUrl__c URL(255) 将来の拡張のための予約
SignatureId SignatureId__c テキスト(255) 署名のID
SignatureTitle SignatureTitle__c テキストエリア(255) 署名のタイトル
Title Title__c テキストエリア(255) タイトル

3. 手書き結果を保存するコントローラーを作成する

次に、手書き結果を書き込むためのコントローラーを作成します。

  1. SurgeryAgreementControllerという名前でApexクラスを新規に作成する。
  2. SurgeryAgreementControllerに次のコードを記述する。
  3. SurgeryAgreementControllerを組織にデプロイします。
public with sharing class SurgeryAgreementController {
    private static string defaultAgreementText = '私は術前、担当医師より手術に関して十分な説明をうけ了解いたしました。よって貴院における手術の実施に同意いたします。なお手術中、他の疾患が発見された場合や不測の事態が生じた場合には、それに応じた適切な処置をとられることに同意いたします。';
    
    private string defaultAgreementTitle = '手術同意書';

    @AuraEnabled
    public static string getDefaultAgreementDate(){
        return Datetime.now().format('yyyy年MM月dd日');
    }
    
    private static string defaultSignatureTitle = '署名欄';

    private static string defaultDoctorTitle =  '院長 殿';

    private static Surgery_Agreement__c signAgreement(string agreementId, string signatureId){
        if (!Surgery_Agreement__c.sObjectType.getDescribe().isAccessible()){return null;}

        if (!Surgery_Agreement__c.sObjectType.getDescribe().isUpdateable()){return null;}

        Surgery_Agreement__c agreement = getAgreementById(agreementId);
        if (agreement == null){
            return null;
        }
        agreement.signatureId__c = signatureId;
        agreement.Patient_Signature__c = signatureId;
        
        update agreement;
        
        return agreement;
    }

    private static Surgery_Agreement__c getAgreementById(string agreementId){
        if (!Surgery_Agreement__c.sObjectType.getDescribe().isAccessible()){return null;}
        
        if (String.isEmpty(agreementId)){
            return null;
        }
        
        Surgery_Agreement__c agreement = [select AgreementText__c, SignatureId__c, PDFPageUrl__c  from Surgery_Agreement__c where Id=:agreementId limit 1];
        return agreement;
    }

    @AuraEnabled
    public static string onPatientSignatureComplete(string patientSignatureId){
        if (!Surgery_Agreement__c.sObjectType.getDescribe().isCreateable()){return null;}

        Surgery_Agreement__c agreement  = new Surgery_Agreement__c();
        agreement.Date__c = getDefaultAgreementDate();
        agreement.DoctorTitle__c = defaultDoctorTitle;
        agreement.SignatureTitle__c = defaultSignatureTitle;
        agreement.AgreementText__c = defaultAgreementText;
        
        insert agreement;
        signAgreement(agreement.Id, patientSignatureId);
        return agreement.Id;
    }

    @AuraEnabled
    public static Id savePDFToDocuments(string agreementId, String base64){
        string fileName = 'Surgery Agreement ' + agreementId + '.pdf';
        ContentVersion cv = new ContentVersion();
        cv.ContentLocation = 'S';
        cv.VersionData = EncodingUtil.base64Decode(base64);
        cv.Title = fileName;
        cv.PathOnClient=fileName; 
        insert cv;
        cv = [select Id, Title, ContentDocumentId from ContentVersion where Id=:cv.Id limit 1];  
        ContentDistribution conDis = new ContentDistribution();
        conDis.Name = cv.Title;
        conDis.ContentVersionId = cv.Id;
        conDis.PreferencesAllowViewInBrowser= true;
        insert conDis; 
        conDis = [SELECT DistributionPublicUrl FROM ContentDistribution WHERE Id =: conDis.Id LIMIT 1];
        URL url = new URL(conDis.DistributionPublicUrl);
        Surgery_Agreement__c agreement = getAgreementById(agreementId);
        agreement.PDFPageUrl__c = url.getPath();
        update agreement;
        return cv.Id;
    }


    @AuraEnabled
    public static string getSignatureBase64(Id signatureId) {
        gcrp__Signature__c signature = [select gcrp__AttachmentId__c from gcrp__Signature__c where Id=:signatureId limit 1];
        if (signature == null) {
            return '';
        }
        Attachment att = [SELECT Id, Name,body FROM Attachment WHERE Id =: signature.gcrp__AttachmentId__c];
        return EncodingUtil.base64Encode(att.body);
    }

    @AuraEnabled
    public static string getPDF(){
        ContentVersion cv = [SELECT VersionData FROM ContentVersion WHERE ContentDocumentId = '069xxxxxxxxxxxxxxx' limit 1];
        return EncodingUtil.base64Encode(cv.VersionData);
    }

}

4. 手書きパッドを表示するLightning Webコンポーネントを作成する

次に、Lightning Webコンポーネントを作成して手書きパッドを表示します。

  1. surgeryAgreementという名前でLightning Webコンポーネントを新規に作成する。
  2. surgeryAgreementに次のコードを記述する。
  3. surgeryAgreementを組織にデプロイします。

surgeryAgreement.css

.agreement-title{
    font-size: 2rem;
    font-weight: bold;
    margin-bottom: 0.67rem;
}
.signature-pad-container {
    box-sizing: content-box;
    max-width: 200px;
    max-height: 100px;
    width: 200px;
    height: 100px;
    border: 1px solid black;
    background-color: white;
    text-align: center;
}

:host {
    --sds-c-button-text-color: black;
}

surgeryAgreement.html

<template>
    <h1 class="agreement-title">{defaultAgreementTitle}</h1>
    <div>
        {defaultDoctorTitle}
    </div>
    <div>
        {defaultAgreementText}
    </div>
    <div>
        {defaultAgreementDate}
    </div>
    <div>
        {defaultSignatureTitle}
    </div>
    <div class="signature-pad-container">
        <gcrp-raypen-lwc placeholder="署名する" signature-button-style="width: 200px !important;
                              height: 100px !important;
                              background-color: white !important;
                              border: none !important;
                              -webkit-appearance: none;
                              background-image: none !important;
                              box-sizing: border-box;
                              border-radius: unset !important;
                              padding: 0 !important;
                              margin: 0 !important;" signature-image-style="max-width: 200px; max-height: 100px;"
            onsignaturecomplete={handleSignatureComplete} footnote="署名の前に契約書を必ずお読みください" footnote-color="#cccccc"
            signature-line-color="#cccccc" title-text="同意書" title-text-color="#000000" ok-button-text="OK"
            cancel-button-text="キャンセル"></gcrp-raypen-lwc>
    </div>

    <div if:true={isLoading} class="slds-is-relative">
        <lightning-spinner
            alternative-text="Loading..." variant="brand">
        </lightning-spinner>
    </div>

    <template if:true={currentSignatureExists}>
        <div style="margin-top: 5px;">
            <lightning-button onclick={handleSavePDFToDocuments} disabled={disableSaveButton} label="PDFとして保存"></lightning-button>
        </div>
        <template if:true={savedPDFFileID}>
            <p>PDFの名前:</p>
            <p>{savedPDFFileName}</p>
            <p>PDFのId:</p>
            <p>{savedPDFFileID}</p>
            <br />
        </template>
    </template>
</template>

surgeryAgreement.js

import { LightningElement, api, track } from 'lwc';
import onPatientSignatureComplete from '@salesforce/apex/SurgeryAgreementController.onPatientSignatureComplete';
import getDefaultAgreementDate from '@salesforce/apex/SurgeryAgreementController.getDefaultAgreementDate';
import savePDFToDocuments from '@salesforce/apex/SurgeryAgreementController.savePDFToDocuments';
import getSignatureBase64 from '@salesforce/apex/SurgeryAgreementController.getSignatureBase64';
import getPDF from '@salesforce/apex/SurgeryAgreementController.getPDF';
import pdflib from "@salesforce/resourceUrl/pdflib";
import SurgeryAgreement from "@salesforce/resourceUrl/SurgeryAgreement";
import { loadScript } from "lightning/platformResourceLoader";

export default class surgeryAgreement extends LightningElement {
    @track defaultAgreementText = '私は術前、担当医師より手術に関して十分な説明をうけ了解いたしました。よって貴院における手術の実施に同意いたします。なお手術中、他の疾患が発見された場合や不測の事態が生じた場合には、それに応じた適切な処置をとられることに同意いたします。';
    @track defaultAgreementTitle = '手術同意書';
    @track defaultAgreementDate = 'yyyy年MM月dd日';
    @track defaultSignatureTitle = '署名欄';
    @track defaultDoctorTitle = '院長 殿';
    @track agreementId;
    @track savedPDFFileName;
    @track savedPDFFileID;
    @track signatureImageBase64;
    @track isLoading = false;
    @track disableSaveButton = false;

    async connectedCallback() {
        try {
            await loadScript(this, pdflib);
            this.defaultAgreementDate = await getDefaultAgreementDate();
        } catch (error) {
            console.log(error);
        }
    }

    async handleSignatureComplete(e) {
        try {
            this.signatureImageBase64 = await getSignatureBase64({ signatureId: e.detail.signatureId });
            this.agreementId = await onPatientSignatureComplete({ patientSignatureId: e.detail.signatureId });
            this.savedPDFFileName = 'Surgery Agreement ' + this.agreementId + '.pdf';
            this.disableSaveButton = false;
        } catch (error) {
            console.log(error);
        }
    };

    get currentSignatureExists() {
        return this.agreementId != null;
    }

    handleSavePDFToDocuments() {
        this.isLoading = true;
        this.disableSaveButton = true;

        // Use setTimeout so that UI can be updated to show loading indicator
        setTimeout(() => {
            this.generatePdf();
        }, 5);
    }

    async generatePdf() {
        try {
            const existingPdfBytes = await fetch(SurgeryAgreement).then(res => res.arrayBuffer());
            const pdfDoc = await PDFLib.PDFDocument.load(existingPdfBytes);
            const pages = pdfDoc.getPages();
            const firstPage = pages[0];

            // Pdf-lib has performance issue with big image, so we need to resize it to small size
            const newImg = document.createElement('img');
            newImg.src = 'data:image/png;base64,' + this.signatureImageBase64;
            newImg.onload = async () => {
                // We create a canvas and get its context.
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');

                // We set the dimensions at the wanted size.
                const wantedWidth = newImg.width * 0.5; 
                const wantedHeight = newImg.height * 0.5;
                canvas.width = wantedWidth;
                canvas.height = wantedHeight;

                // We resize the image with the canvas method drawImage();
                ctx.drawImage(newImg, 0, 0, wantedWidth, wantedHeight);

                const resizedImageBase64 = canvas.toDataURL("image/png", 1.0);

                // Continue generating pdf
                const pngImage = await pdfDoc.embedPng(resizedImageBase64);
                const pngDims = pngImage.scale(0.2);
                let date = new Date();
                firstPage.drawText(date.getFullYear().toString(), {
                    x: 151,
                    y: firstPage.getHeight() / 2 + 26,
                    size: 13,
                    color: PDFLib.rgb(0, 0, 0)
                });
                firstPage.drawText((date.getMonth() + 1).toString(), {
                    x: 197,
                    y: firstPage.getHeight() / 2 + 26,
                    size: 13,
                    color: PDFLib.rgb(0, 0, 0)
                });
                firstPage.drawText(date.getDate().toString(), {
                    x: 224,
                    y: firstPage.getHeight() / 2 + 26,
                    size: 13,
                    color: PDFLib.rgb(0, 0, 0)
                });
                firstPage.drawImage(pngImage, {
                    x: firstPage.getWidth() - 164 - pngDims.width / 2,
                    y: firstPage.getHeight() / 2 - 46 - (pngDims.height * 7/8),
                    width: pngDims.width,
                    height: pngDims.height,
                })
                const pdfBytes = await pdfDoc.save();
                await this.saveByteArray(pdfBytes);
            }
        } catch (error) {
            console.log(error);
        }
    };

    async saveByteArray(pdfBytes) {
        const blob = new Blob([pdfBytes], { type: 'application/pdf' });
        const reader = new FileReader();
        reader.readAsDataURL(blob);
        reader.onloadend = async () => {
            const base64String = reader.result.split(',')[1];
            try {
                this.savedPDFFileID = await savePDFToDocuments({ agreementId: this.agreementId, base64: base64String });
            } catch (error) {
                console.log(error);
            } finally {
                this.isLoading = false;
            }
        }
    }
}

surgeryAgreement.js-meta

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>55.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
        <target>lightning__RecordPage</target>
        <target>lightning__Tab</target>
    </targets>
</LightningComponentBundle>

次に、作成したLightning WebコンポーネントをSalesforceモバイルアプリケーションから表示可能にします。

  1. Salesforce Classicの場合、「設定 > ビルド > 作成 > タブ」をクリックする。Lightning Experienceの場合、歯車アイコンをクリックした後「設定 > プラットフォームツール > ユーザーインタフェース > タブ」をクリックする。
  2. 「Lightning コンポーネントタブ」の「新規」ボタンをクリックする。
  3. 「Lightning コンポーネント」に「c:surgeryAgreement」を選択する。
  4. 「タブの表示ラベル」に「同意書(LWC)」を入力する。
  5. 「タブ名」に「SurgeryAgreementLWCApp」を入力する。
  6. 「タブスタイル」に任意のスタイルを選択する。
  7. 「次へ」をクリックし、タブをプロファイルに割り当て、保存する。

5. 動作を確認する

Salesforceモバイルアプリケーションを使用して動作を確認します。

  1. Salesforceモバイルアプリケーションを起動し、Salesforceにログインする。
  2. メニューから「同意書(LWC)」をタップする。
  3. 「署名する」をタップする。
  4. 「同意書」画面で、手書きを試す。
  5. 「OK」ボタンをクリックする。
  6. 画面に保存された手書き画像が表示され、「PDFとして保存」ボタンが表示される。
  7. 「PDFとして保存」ボタンをクリックする。
  8. 「PDFの名前」および「PDFのID」が表示される。
  9. 「ファイル」を開き、ファイルの一覧から作成したPDFを選択してPDFドキュメントを確認する。

このページでのPDFに手書き結果を埋め込む方法は、実際にはRayPenの機能とは関係がなく、Salesforceでの開発の1例であることに注意してください。

Copyright © 2024 MESCIUS inc. All rights reserved.