ここでは、RayPenをLightning Webコンポーネントに配置し、ユーザーによる手書きと手書き結果を表示する手順を説明します。ユースケースとして病院において手術を実施する前に患者の同意を得るシーンを想定しています。紙の契約書への署名の手続きの代わりに、Salesforce上の契約書と手書きの署名を行うサンプルです。なお、ここで説明する内容はサンプルパッケージでインストールすることもできます。
このページでの手順は、Salesforce CLIやVisual Studio CodeなどLightning Webコンポーネントの開発環境が整っており、組織を操作するSalesforce DXプロジェクトでLightning WebコンポーネントやApexクラスなどを作成およびデプロイすることを前提としています。
このサンプルでは最後に手書き結果画像を利用してPDFを作成します。PDFの作成にはpdf-libを利用します。以下の2つを組織の静的リソースに格納します。それぞれのリンクからダウンロードしてください。
まず、手書き結果を保存するためにカスタムオブジェクトを作成します。
作成した「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) | タイトル |
次に、手書き結果を書き込むためのコントローラーを作成します。
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);
}
}
次に、Lightning Webコンポーネントを作成して手書きパッドを表示します。
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モバイルアプリケーションから表示可能にします。
Salesforceモバイルアプリケーションを使用して動作を確認します。
このページでのPDFに手書き結果を埋め込む方法は、実際にはRayPenの機能とは関係がなく、Salesforceでの開発の1例であることに注意してください。