Customize the UI for PDF annotations using JavaScript
A completely custom UI for working with annotation style attributes using an inline popup. Get additional resources by visiting our guide about working with annotations.
/* * In this example we hide the default annotation * toolbar in favor of our own custom UI widget to * visualize the current value of the annotations properties * and let the user update them at will. We call it "annotation inspector". * * We specify our own DOM node to render as an annotation * tooltip, attach several DOM form controls to it, and * update the annotation properties with the new values. * * Additionally, we specify our own custom CSS style sheet * to customize the appearance of our tooltip at will. * * To keep the example simple, with minimal dependencies, * we are only using the browser’s native controls and DOM API. */
import PSPDFKit from "@nutrient-sdk/viewer";
let instance = null;
export function load(defaultConfiguration) { return PSPDFKit.load({ ...defaultConfiguration, styleSheets: ["/annotations-inspector/static/style.css"], annotationTooltipCallback: getAnnotationInspector, initialViewState: new PSPDFKit.ViewState({ enableAnnotationToolbar: false, }), }).then((_instance) => { instance = _instance;
return instance; });}
// cache of inspector DOM elements to reuse the existing ones in case// the user is selecting the same annotation again after the first time.let inspectors = {};
function getAnnotationInspector(annotation) { let container;
if ( !inspectors[annotation.id] || inspectors[annotation.id].children.length === 0 ) { // There's a bug on IE 11 where the cached div can be empty // so if it doesn't contain any children we recreate the container container = document.createElement("div"); container.className = "annotation-inspector";
const { sections, title } = getPropertiesByType(annotation);
const header = document.createElement("header");
header.innerHTML = `<h1>${title}</h1>`; container.appendChild(header); sections.forEach((section) => { container.appendChild(section); }); inspectors[annotation.id] = container; }
container = inspectors[annotation.id];
return [ { type: "custom", id: "annotation-tooltip", className: "annotation-tooltip-container", node: container, }, ];}
// By default we would like to have the inspector tooltip sections collapsed// until a certain breakpoint (for better UX on mobile devices) and expanded// on larger viewport dimensions (like tablets or desktop).const COLLAPSE_BY_DEFAULT_HEIGHT = 850;const COLLAPSE_BY_DEFAULT_WIDTH = 600;
// Construct the DOM tree for the different sections to display according// to the current annotation type.function getPropertiesByType(annotation) { const sections = []; let title; const pushSectionForType = (annotationKey, label) => { const ul = document.createElement("ul");
Object.keys(annotationsMap[annotationKey]).forEach((prop) => { const { label, getElements, icon } = annotationsMap[annotationKey][prop]; const domElements = getElements();
if (Array.isArray(domElements) && domElements.length > 0) { ul.appendChild(generateListItem(label, prop, domElements, icon)); } });
// If there's enough width the inspector can be rendered on the sides of // the annotation even when the height of the viewport wouldn't be enough const shouldCollapseSectionsByDefault = window.innerHeight <= COLLAPSE_BY_DEFAULT_HEIGHT && window.innerWidth <= COLLAPSE_BY_DEFAULT_WIDTH;
if (shouldCollapseSectionsByDefault) { ul.classList.toggle("collapsed-list"); }
const sectionIcon = shouldCollapseSectionsByDefault ? "panel-expand.svg" : "panel-collapse.svg"; const sectionEl = document.createElement("section"); const collapseBtn = document.createElement("button");
collapseBtn.className = "collapse-toggle-button";
const collapseImg = document.createElement("img");
collapseImg.src = `/annotations-inspector/static/${sectionIcon}`; collapseImg.setAttribute("alt", "Expand"); collapseBtn.appendChild(collapseImg); collapseBtn.id = `collapse-${annotationKey}`;
const sectionDiv = document.createElement("div");
sectionDiv.className = "section-title"; sectionDiv.innerHTML = `<label class="group-title" for="${collapseBtn.id}">${label}</label>`; sectionDiv.firstChild.appendChild(collapseBtn); sectionEl.appendChild(sectionDiv); sectionEl.appendChild(ul); collapseBtn.addEventListener("click", () => { if (ul.classList.contains("collapsed-list")) { collapseImg.src = "/annotations-inspector/static/panel-collapse.svg"; collapseImg.setAttribute("alt", "Collapse"); } else { collapseImg.src = "/annotations-inspector/static/panel-expand.svg"; collapseImg.setAttribute("alt", "Expand"); }
ul.classList.toggle("collapsed-list"); }); sections.push(sectionEl); };
pushSectionForType("annotation", "Container style");
if (annotation instanceof PSPDFKit.Annotations.ShapeAnnotation) { pushSectionForType("shapeAnnotation", "Shape style"); title = "Shape annotation properties"; } else if (annotation instanceof PSPDFKit.Annotations.InkAnnotation) { pushSectionForType("inkAnnotation", "Ink style"); title = "Ink annotation properties"; } else if (annotation instanceof PSPDFKit.Annotations.TextAnnotation) { pushSectionForType("textAnnotation", "Text style"); title = "Text annotation properties"; } else if (annotation instanceof PSPDFKit.Annotations.NoteAnnotation) { pushSectionForType("noteAnnotation", "Note style"); } else if (annotation instanceof PSPDFKit.Annotations.HighlightAnnotation) { pushSectionForType("highlightAnnotation", "Highlight style"); title = "Highlight annotation properties"; }
// we reverse the array to show the most specific properties first return { title, sections: sections.reverse() };}
// Generate a row for the annotation inspector for a given property.function generateListItem(label, key, domElements, icon) { const li = document.createElement("li"); const valueSpan = document.createElement("span");
valueSpan.className = "val";
if (Array.isArray(domElements)) { domElements.forEach((el) => { valueSpan.appendChild(el); }); }
li.innerHTML = `<span class="attr"><img src="${icon}" class="property-icon" aria-hidden="true" alt=""> ${label}</span>`; li.appendChild(valueSpan);
return li;}
const blendModes = [ { label: "Normal", value: "normal" }, { label: "Multiply", value: "multiply" }, { label: "Screen", value: "screen" }, { label: "Overlay", value: "overlay" }, { label: "Darken", value: "darken" }, { label: "Lighten", value: "lighten" }, { label: "Color Dodge", value: "colorDodge" }, { label: "Color Burn", value: "colorBurn" }, { label: "Hard Light", value: "hardLight" }, { label: "Soft Light", value: "softLight" }, { label: "Difference", value: "difference" }, { label: "Exclusion", value: "exclusion" },];
const ASSETS_PATH = "/annotations-inspector/static";
// Base radius for cloudy border arcs. It's multiplied by// cloudyBorderIntensity to obtain the actual radius. The// strokeWidth is added to get a working value for// cloudyBorderInset so that its size is slightly less than// the boundingBox, to make it fit nicely.const CLOUD_BORDER_EFFECT_BASE_RADIUS = 4.25;
// Object with the properties to display in inspector by type.// We specify for each property the DOM element that we want// to render on the inspector. For this, we prepared some// generic implementations but we provide custom code// for specific cases.const annotationsMap = { annotation: { opacity: { label: "Opacity", icon: `${ASSETS_PATH}/opacity.svg`, getElements: () => { if ( !( instance.getSelectedAnnotation() instanceof PSPDFKit.Annotations.NoteAnnotation ) ) { return numberInput("opacity", { max: 1, step: 0.01, type: "range" }); } }, }, }, shapeAnnotation: { fillColor: { label: "Fill color", icon: `${ASSETS_PATH}/fill-color.svg`, getElements: () => colorPicker("fillColor"), }, strokeColor: { label: "Stroke color", icon: `${ASSETS_PATH}/color.svg`, getElements: () => colorPicker("strokeColor"), }, strokeWidth: { label: "Stroke width", icon: `${ASSETS_PATH}/stroke-width.svg`, getElements: () => numberInput("strokeWidth", { min: 0, step: 0.5 }), }, strokeDashArray: { label: "Line style", icon: `${ASSETS_PATH}/line-style.svg`, getElements: () => { const annotation = instance.getSelectedAnnotation(); const possibleValues = [ { label: "solid", value: null }, { label: "dashed", value: [1, 1] }, { label: "dotted", value: [1, 3] }, { label: "dashed (3)", value: [3, 3] }, { label: "dashed (6)", value: [6, 6] }, ];
if ("cloudyBorderIntensity" in annotation) { // Cloudy style is only supported on a subset of shape annotations possibleValues.push({ label: "cloudy", value: null }); }
const [select] = createSelectField("strokeDashArray", possibleValues);
// Add another event listener to update the // "cloudyBorderIntensity" property when "cloudy" // is selected if (instance.getSelectedAnnotation().cloudyBorderIntensity > 0) { select.selectedIndex = 5; }
select.addEventListener("change", (e) => { const annotation = instance.getSelectedAnnotation();
if (e.target.selectedIndex === 5) { // For cloudy borders, we set "strokeDashArray" to null // but specify "cloudyBorderIntensity" and "cloudyBorderInset" const inset = CLOUD_BORDER_EFFECT_BASE_RADIUS * 2 + annotation.strokeWidth / 2; const updatedAnnotation = annotation .set("cloudyBorderIntensity", 2) .set( "cloudyBorderInset", PSPDFKit.Geometry.Inset.fromValue(inset) );
instance.update(updatedAnnotation); } else if (annotation.cloudyBorderIntensity > 0) { const updatedAnnotation = annotation.set( "cloudyBorderIntensity", 0 );
instance.update(updatedAnnotation); } });
return [select]; }, }, }, inkAnnotation: { lineWidth: { label: "Line width", icon: `${ASSETS_PATH}/stroke-width.svg`, getElements: () => numberInput("lineWidth", { min: 0, step: 0.5 }), }, strokeColor: { label: "Stroke color", icon: `${ASSETS_PATH}/color.svg`, getElements: () => colorPicker("strokeColor"), }, backgroundColor: { label: "Background", icon: `${ASSETS_PATH}/fill-color.svg`, getElements: () => colorPicker("backgroundColor"), }, blendMode: { label: "Blend mode", icon: `${ASSETS_PATH}/blend-mode.svg`, getElements: () => createSelectField("blendMode", blendModes), }, }, textAnnotation: { font: { label: "Font", icon: `${ASSETS_PATH}/font.svg`, getElements: () => createSelectField("font", [ "Helvetica", "Arial", "Calibri", "Century Gothic", "Consolas", "Courier", "Dejavu Sans", "Dejavu Serif", "Georgia", "Gill Sans", "Impact", "Lucida Sans", "Myriad Pro", "Open Sans", "Palatino", "Tahoma", "Times New Roman", "Trebuchet", "Verdana", "Zapfino", "Comic Sans", ]), }, fontSyle: { label: "Font style", icon: `${ASSETS_PATH}/font-style.svg`, getElements: () => toggleFields(["isItalic", "isBold"], { type: "checkbox", options: [ { label: "Italic", icon: `${ASSETS_PATH}/italic.svg`, }, { label: "Bold", icon: `${ASSETS_PATH}/bold.svg`, }, ], }), }, fontSize: { label: "Text size", icon: `${ASSETS_PATH}/font-size.svg`, getElements: () => numberInput("fontSize", { min: 0, step: 1 }), }, fontColor: { label: "Text color", icon: `${ASSETS_PATH}/color.svg`, getElements: () => colorPicker("fontColor"), }, horizontalAlign: { label: "Horizontal alignment", icon: `${ASSETS_PATH}/text-align-horizontal.svg`, getElements: () => toggleFields("horizontalAlign", { options: [ { label: "Left", value: "left", icon: `${ASSETS_PATH}/align-left.svg`, }, { label: "Center", value: "center", icon: `${ASSETS_PATH}/align-center.svg`, }, { label: "Right", value: "right", icon: `${ASSETS_PATH}/align-right.svg`, }, ], }), }, verticalAlign: { label: "Vertical alignment", icon: `${ASSETS_PATH}/text-align-vertical.svg`, getElements: () => toggleFields("verticalAlign", { options: [ { label: "Top", value: "top", icon: `${ASSETS_PATH}/align-top.svg`, }, { label: "Center", value: "center", icon: `${ASSETS_PATH}/align-vcenter.svg`, }, { label: "Bottom", value: "bottom", icon: `${ASSETS_PATH}/align-bottom.svg`, }, ], }), }, backgroundColor: { label: "Background", icon: `${ASSETS_PATH}/fill-color.svg`, getElements: () => colorPicker("backgroundColor"), }, }, noteAnnotation: { color: { label: "Background", icon: `${ASSETS_PATH}/fill-color.svg`, getElements: () => colorPicker("color", false), }, icon: { label: "Icon", icon: `${ASSETS_PATH}/color.svg`, getElements: () => createSelectField("icon", [ { label: "Comment", value: "COMMENT" }, { label: "Right Pointer", value: "RIGHT_POINTER" }, { label: "Right Arrow", value: "RIGHT_ARROW" }, { label: "Check", value: "CHECK" }, { label: "Circle", value: "CIRCLE" }, { label: "Cross", value: "CROSS" }, { label: "Insert", value: "INSERT" }, { label: "New Paragraph", value: "NEW_PARAGRAPH" }, { label: "Note", value: "NOTE" }, { label: "Paragraph", value: "PARAGRAPH" }, { label: "Help", value: "HELP" }, { label: "Star", value: "STAR" }, { label: "Key", value: "KEY" }, ]), }, }, highlightAnnotation: { color: { label: "Color", icon: `${ASSETS_PATH}/color.svg`, getElements: () => colorPicker("color"), }, blendMode: { label: "Blend mode", icon: `${ASSETS_PATH}/blend-mode.svg`, getElements: () => createSelectField("blendMode", blendModes), }, },};
// Generator of input[type="color"] elements (with a fallback to// input[type="text"] for IE 11).function colorPicker(prop, nullable = true) { const currentColor = instance.getSelectedAnnotation()[prop]; const colorPicker = document.createElement("input");
try { colorPicker.type = "color"; } catch (e) { colorPicker.type = "text"; colorPicker.className = "annotation-input"; colorPicker.placeholder = "(e.g.: #ff00aa)"; }
colorPicker.name = prop; colorPicker.value = currentColor ? // input[type="color"] only support hexadecimal // values. rgbToHex(currentColor.toCSSValue()) : "#ffffff";
// Event handler for "change" events. We throttle it // to prevent multiple calls when the user is exploring // color options using the browser's color picker wheel. const throttledChange = throttled(50, (event) => { const updatedAnnotation = instance .getSelectedAnnotation() // we need to convert the hex value representation used by the // input[type="color"] element to the RGB value expected. .set(prop, new PSPDFKit.Color(hexToRgb(event.target.value)));
instance.update(updatedAnnotation); });
colorPicker.addEventListener("change", throttledChange);
if (nullable) { const checkbox = document.createElement("input");
checkbox.type = "checkbox"; checkbox.name = `transparent-${prop}`; checkbox.checked = instance.getSelectedAnnotation()[prop];
if (!checkbox.checked) { colorPicker.style.visibility = "hidden"; }
checkbox.addEventListener("change", (e) => { colorPicker.style.visibility = e.target.checked ? "visible" : "hidden";
const updatedAnnotation = instance.getSelectedAnnotation().set( prop, e.target.checked ? // we need to convert the hex value representation used by the // input[type="color"] element to the RGB value expected. new PSPDFKit.Color(hexToRgb(colorPicker.value)) : null );
instance.update(updatedAnnotation); });
return [checkbox, colorPicker]; }
return [colorPicker];}
// Generator of either input[type="number"] or input[type="range"]// elements, according to the "type" property received as a parameter.function numberInput(prop, { min = 0, step, max, type = "number" }) { const input = document.createElement("input");
input.type = type; input.className = type === "number" ? "annotation-input" : "annotation-range"; input.name = prop; input.min = min;
if (step) { input.step = step; }
if (max) { input.max = max; }
input.value = instance.getSelectedAnnotation()[prop];
let label;
if (type === "range") { label = document.createElement("label"); label.style.width = "2em"; label.style.display = "inline-block"; label.innerText = input.value; }
// for input[type="number"] we listen to the "input" event // to prevent an edge case on mobile devices when the annotation // is deselected but the current value has changed. const event = type === "range" ? "change" : "input";
input.addEventListener(event, (e) => { const value = parseFloat(e.target.value); const currentAnnotation = instance.getSelectedAnnotation(); let updatedAnnotation = currentAnnotation.set(prop, value);
if (label) { label.innerText = value; }
if ( prop === "strokeWidth" && updatedAnnotation.cloudyBorderIntensity > 0 && updatedAnnotation.cloudyBorderInset ) { updatedAnnotation = updatedAnnotation.set( "cloudyBorderInset", new PSPDFKit.Geometry.Inset({ left: updatedAnnotation.cloudyBorderInset.left + (value - currentAnnotation.strokeWidth) / 2, top: updatedAnnotation.cloudyBorderInset.top + (value - currentAnnotation.strokeWidth) / 2, right: updatedAnnotation.cloudyBorderInset.right + (value - currentAnnotation.strokeWidth) / 2, bottom: updatedAnnotation.cloudyBorderInset.bottom + (value - currentAnnotation.strokeWidth) / 2, }) ); }
instance.update(updatedAnnotation); });
return label ? [input, label] : [input];}
// Generator of select elements given an array of options.// For simple cases you can just specify string values on the array.// Otherwise add { label, value } objects as entries.function createSelectField(prop, options = []) { const select = document.createElement("select");
select.className = "annotation-select";
const currentVal = instance.getSelectedAnnotation()[prop]; const optionsHTML = options .map((option, i) => { const optionValue = option.value || option; const areValuesEqual = Array.isArray(optionValue) ? JSON.stringify(optionValue) === JSON.stringify(currentVal) : currentVal === optionValue;
return `<option value=${i} ${areValuesEqual ? "selected" : ""}>${ option.label || option }</option>`; }) .join("");
select.innerHTML = optionsHTML; select.addEventListener("change", (e) => { const updatedAnnotation = instance .getSelectedAnnotation() .set( prop, options[parseInt(e.target.value)].value === undefined ? options[parseInt(e.target.value)] : options[parseInt(e.target.value)].value );
instance.update(updatedAnnotation); });
return [select];}
// Group of buttons to handle radio buttons or checkboxes interactions.// In the case of checkbox-alike interactions, one should specif an array of// boolean properties that matches each of the possible options to display.// One example of this is the "Text style" row when inspecting text// annotations.function toggleFields(prop, { type = "radio", options = [] }) { const form = document.createElement("form");
form.className = "switch-form";
let currentVal;
if (type === "radio") { currentVal = instance.getSelectedAnnotation()[prop]; }
options.forEach((option, i) => { const input = document.createElement("input");
input.type = type; input.id = `${type}-${prop}-${option.label || ""}`; input.name = prop;
if (type === "radio") { input.value = option.value; }
if (Array.isArray(prop)) { // For the checkboxes implementation, one can specify // an array with properties to bind to each of the inputs. input.checked = !!instance.getSelectedAnnotation()[prop[i]]; } else { if (option.value === currentVal) { input.checked = true; } }
const label = document.createElement("label");
label.htmlFor = input.id; label.setAttribute("role", "button");
const icon = document.createElement("img");
icon.setAttribute("src", option.icon); icon.setAttribute("alt", option.label); label.appendChild(icon);
if (type === "checkbox") { // In this case we can attach a change event listener // for each checkbox. input.addEventListener("change", (e) => { let updatedAnnotation;
if (Array.isArray(prop)) { updatedAnnotation = instance .getSelectedAnnotation() .set(prop[i], e.target.checked); } else { updatedAnnotation = instance .getSelectedAnnotation() .set(prop, e.target.checked); }
instance.update(updatedAnnotation); }); }
form.appendChild(input); form.appendChild(label); });
if (type === "radio") { // For the radio buttons implementation, we attach the // change event handler to the form, to easily identify // the active radio button. form.addEventListener("change", () => { const selectedRadio = form.querySelector("input:checked"); const updatedAnnotation = instance .getSelectedAnnotation() .set(prop, selectedRadio.value);
instance.update(updatedAnnotation); }); }
return [form];}
// Helper functions to convert between RGB and Hex values.function rgbToHex(rgb) { const csv = rgb.split("(")[1].split(")")[0]; const split = csv.split(","); const [r, g, b] = [split[0].trim(), split[1].trim(), split[2].trim()];
return `#${rgbPartToHex(r)}${rgbPartToHex(g)}${rgbPartToHex(b)}`;}
function hexToRgb(hex) { const numberPart = hex.split("#")[1]; const number = parseInt(numberPart, 16);
return { r: (number >> 16) & 255, g: (number >> 8) & 255, b: number & 255, };}
function rgbPartToHex(part) { const number = Number.parseInt(part, 10); const hex = number.toString(16);
return hex.length == 1 ? "0" + hex : hex;}
// Basic implementation of a throttle function.function throttled(delay, fn) { let lastCall = 0;
return (...args) => { const now = Date.now();
if (now - lastCall < delay) { return; }
lastCall = now;
return fn(...args); };}
This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.