Implementing a Custom Text-Based Signature Dialog for Web

Illustration: Implementing a Custom Text-Based Signature Dialog for Web

A common workflow for PDF documents is to present users with a form that contains a field that, once clicked, allows users to draw a signature that’s then added to a document. An example of this on PSPDFKit for Web can be seen in the Form example of our Catalog.

In this post, we’ll cover how to implement a different workflow that achieves the same result. What about if, instead of making users draw a signature, we allow them to type their names into a text input and we generate a signature based on that?

Even though this isn’t the standard workflow provided by PSPDFKit for Web, implementing something like this is a straightforward process.

You can go ahead and try out the complete example by following this CodeSandbox link. You’ll need to replace the "YOUR_LICENSE_KEY" string with a trial license key that you can obtain by following this link. Once you add it and refresh the CodeSandbox preview, the error message will disappear and the example will run.

Intercepting Signature Widgets Presses

The first step is to add an annotations.press event handler, which adds our custom logic when signature fields are pressed by the user.

Based on the steps outlined in the Override Ink Signature Dialog entry in the Knowledge Base, event.preventDefault() is used to avoid showing the default ink signature workflow when clicking on a widget annotation, and in the case of signature form fields, we render the signing modal instead:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
instance.addEventListener("annotations.press", event => {
  if (event.annotation instanceof PSPDFKit.Annotations.WidgetAnnotation) {
    event.preventDefault();
  }

  instance.getFormFields().then(formFields => {
    const field = formFields.find(
      field => field.name === event.annotation.formFieldName
    );
    if (field instanceof PSPDFKit.FormFields.SignatureFormField) {
      renderSigningModal(event.annotation);
    }
  });
});

Rendering Our Own Signing Modal

For this example, we’ll be using React to render our modal. For accessibility, we’re appending the inert attribute to the PSPDFKit container to avoid making it interactive or reachable in any way while the modal is open. Since support for inert is still lacking, we include a polyfill script for it:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function renderSigningModal(widgetAnnotation) {
  const mainContainer = document.querySelector(".container");
  const pspdfkitContainer = mainContainer.querySelector(".PSPDFKit-Container");
  const reactRoot = document.createElement("div");
  reactRoot.className = "react-root";
  mainContainer.appendChild(reactRoot);
  pspdfkitContainer.setAttribute("inert", "");
  const handleClose = () => {
    mainContainer.removeChild(reactRoot);
    pspdfkitContainer.ariaHidden = null;
    pspdfkitContainer.removeAttribute("inert");
  };

  const handleSelection = async imgBlob => {
    annotationFromBlob(imgBlob, widgetAnnotation);
    instance.updateAnnotation(widgetAnnotation.set("noView", true));
  };

  ReactDOM.render(
    <SigningDialog onClose={handleClose} onSelect={handleSelection} />,
    reactRoot
  );
  pspdfkitContainer.ariaHidden = true;
}

The SigningDialog React component receives the onClose and onSelect callbacks as props. onSelect will be invoked once the user confirms one of the available options from this dialog. Regardless of whether the user makes a selection or discards the whole process, onClose will be invoked to close the dialog:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const SigningDialog = ({ onClose, onSelect }) => {
  const [name, setName] = React.useState("");
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }, []);

  const handleKeyPress = React.useCallback(
    e => {
      if (e.key === "Escape") {
        onClose();
      }
    },
    [onClose]
  );

  // ...
};

The only piece of state we need for our component is the name value the user wants to use for the signature. When the component is mounted, we focus on the input, as it’s the first focusable element, and placing focus on the first focusable element is recommended by the WAI-ARIA Authoring Practices 1.1. Then we set up the logic for closing the dialog when the Escape key is pressed.

Let’s take a look at the rendered UI:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const SigningDialog = ({ onClose, onSelect }) => {
  // ...

  return (
    <div
      className="signing-modal"
      role="dialog"
      aria-modal="true"
      onKeyDown={handleKeyPress}
      aria-label="Signature"
    >
      <form className="signing-form">
        <label htmlFor="name-input" className="name-label">
          Name:
        </label>
        <input
          type="text"
          className="signing-input"
          id="name-input"
          value={name}
          onChange={e => setName(e.target.value)}
          ref={inputRef}
        />
        <div className="signing-card">
          {["Arial", "Times New Roman", "Didot", "Brush Script MT"].map(
            font => (
              <button
                className="signing-card-btn"
                style={{
                  fontFamily: font
                }}
                key={font}
                type="button"
                aria-label={`Signature on ${font} font`}
                onClick={() => {
                  onClose();
                  generateImage(name, font);
                }}
                disabled={name === ""}
              >
                <p className="signing-preview-p">{name}</p>
              </button>
            )
          )}
        </div>
        <button className="signing-cancel-btn" type="button" onClick={onClose}>
          Cancel
        </button>
      </form>
    </div>
  );
};

I’m relying on web safe fonts to avoid complicating the example with custom fonts, but the options are endless! For each available font, a preview is rendered inside a button to confirm the selection.

Generating an Image from the Signature

The magic happens inside generateImage, which takes the name value and the selected font. It generates an image and passes it on a call to the onSelect callback prop:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const generateImage = React.useCallback(
  async (name, font) => {
    let canvas, convertToBlob;
    if ("OffscreenCanvas" in window) {
      canvas = new OffscreenCanvas(200, 200);
      convertToBlob = canvas.convertToBlob.bind(canvas);
    } else {
      canvas = document.createElement("canvas");
      convertToBlob = () =>
        new Promise(resolve => {
          canvas.toBlob(resolve);
        });
    }

    const ctx = canvas.getContext("2d");
    ctx.font = `64px ${font}`;
    const {
      actualBoundingBoxLeft,
      actualBoundingBoxRight,
      actualBoundingBoxAscent,
      actualBoundingBoxDescent
    } = ctx.measureText(name);
    canvas.height = actualBoundingBoxAscent + actualBoundingBoxDescent + 10;
    canvas.width = actualBoundingBoxLeft + actualBoundingBoxRight + 10;
    ctx.font = `64px ${font}`;
    ctx.textBaseline = "top";
    ctx.fillText(name, 0, 0);
    const blob = await convertToBlob({
      type: "image/png"
    });
    onSelect(blob);
  },
  [onSelect]
);

If available, OffscreenCanvas is preferred, as it minimizes work on the main thread. As a fallback, a regular HTML5 Canvas is used instead. For our use case, the main difference between OffscreenCanvas and Canvas is their individual APIs for converting the rendered bitmap into a Blob. I’m abstracting these APIs away into a common convertToBlob async function.

The height and width occupied by the text is measured using the measureText method, and the canvas is resized based on that. Finally, onSelect is called with the generated PNG image blob.

Please be aware that the TextMetrics API is not fully supported on Internet Explorer 11, but it has good support on modern browsers.

Thus far, we’ve seen how to generate a PNG with the text we want to sign with. Now we need to create an image annotation from it.

We follow these steps:

  1. Create an attachment from the blob.
  2. Create an object URL from the blob to render it into an HTMLImageElement. This way, we can retrieve the dimensions of the image.
  3. Using the image dimensions, reduce the image into half its size, and then place it accordingly in the center of the widget annotation’s bounding box.
  4. Create a new image annotation from our attachment with our calculated bounding box, linking it to the widget annotation as customData (we’re going to see why later on).
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
async function annotationFromBlob(imgBlob, widgetAnnotation) {
  const attachmentId = await instance.createAttachment(imgBlob);
  const img = new Image();
  const url = URL.createObjectURL(imgBlob);
  img.onload = async () => {
    const contentRect = new PSPDFKit.Geometry.Rect({
      width: img.width,
      height: img.height
    });
    const transformedRect = instance.transformClientToPageSpace(contentRect, 0);
    const halfSize = {
      width: transformedRect.width * 0.5,
      height: transformedRect.height * 0.5
    };
    const left =
      widgetAnnotation.boundingBox.left +
      widgetAnnotation.boundingBox.width / 2 -
      halfSize.width / 2;
    const top =
      widgetAnnotation.boundingBox.top +
      widgetAnnotation.boundingBox.height / 2 -
      halfSize.height / 2;

    let annotation = new PSPDFKit.Annotations.ImageAnnotation({
      pageIndex: widgetAnnotation.pageIndex,
      contentType: "image/png",
      imageAttachmentId: attachmentId,
      boundingBox: new PSPDFKit.Geometry.Rect({
        left,
        top,
        width: halfSize.width,
        height: halfSize.height
      }),
      customData: {
        widgetId: widgetAnnotation.id
      }
    });
    await instance.createAnnotation(annotation);
  };
  img.src = url;
}

And that’s it! We’re now letting a user type their name, and we generate an image out of it.

Toggling Visibility of Signature Widgets

However, there’s a minor detail we can improve upon.

Since the widget annotations are meant to be used with regular ink annotations, their “sign” calls to action and logic will still appear after image annotations are added on top of them. We could hide this text using CSS, but for this case, it might be a better option to simply hide the widget annotation altogether once the image annotation is placed.

That’s easily done by changing the noView flag of the widget annotation. So we’ll do this while we generate the annotation from the blob, as we saw earlier:

Copy
1
2
3
4
const handleSelection = async imgBlob => {
  annotationFromBlob(imgBlob, widgetAnnotation);
  instance.updateAnnotation(widgetAnnotation.set("noView", true));
};

Finally, if an image annotation linked to the widget annotation is deleted, we’ll need to show the widget again so that a new signature can be added in its place, which is why we’ll store the widgetId as custom data of the image annotation:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
instance.addEventListener("annotations.delete", async annotations => {
  const annotation = annotations.first();
  if (
    annotation instanceof PSPDFKit.Annotations.ImageAnnotation &&
    annotation.customData &&
    annotation.customData.widgetId
  ) {
    const annotations = await instance.getAnnotations(annotation.pageIndex);
    const widgetAnnotation = annotations.find(
      annot => annot.id === annotation.customData.widgetId
    );
    if (widgetAnnotation) {
      instance.updateAnnotation(widgetAnnotation.set("noView", false));
    }
  }
});

Conclusion

And that’s it! Thank you for following along with this example of implementing a custom flow for adding signatures to a document. As we’ve seen, we can make use of native Web APIs to bring our own interactions and opt out of the default ones. This is just one of the many possibilities enabled by the flexibility of PSPDFKit for Web. The full example is available at this CodeSandbox link.

PSPDFKit for Web

PDF viewing, annotating, and collaboration for web apps.

Try Now