
























Implementing custom keyboard shortcuts in web apps can get tricky, especially when you want to have a different way of handling things compared to the default input of Nutrient Web SDK. This guide walks through six reliable patterns for predictable, conflict-free keyboard handling that work across Shadow DOM and iframe setups.
The examples listed here have been tested with Nutrient Web SDK 1.8.0. All code examples are production-ready and available to test immediately in our Playground.
This article is for developers who need to know how to:
All examples work in both Shadow DOM and iframe setups. The patterns behave the same because they intercept keyboard events before implementation details matter.
Before diving into the code, it’s important to first establish the foundation that makes these patterns work.
When you press a key, the browser processes that event in three distinct phases:
By default, addEventListener listens during the bubbling phase, meaning your handler runs after the event reaches its target. But for keyboard shortcuts, you want the capturing phase (addEventListener(..., true)). This lets your handlers run before the viewer’s handlers, giving you first dibs on every keystroke.
Nutrient Web SDK can be embedded in two main ways, each with its own event behavior.
Shadow DOM:
event.target to reference the shadow host instead of the internal element. If you need the actual internal element, use event.composedPath()[0] instead of event.target.document and inside the viewer’s shadow root, you may see the same key event fire twice. Keep your listener in one place to avoid duplication.iframe:
instance.contentDocument (not the host page).The critical rule is to attach your listener to one place, never both:
// For Shadow DOM:
document.addEventListener("keydown", handler, true);
// For iframe:
instance.contentDocument.addEventListener("keydown", handler, true);
All patterns shown below work identically for both models — just swap the document reference.
Each pattern solves a specific keyboard handling challenge with its relevant code snippet, and you can try every single one right now.
Every example includes a link to the Playground that opens a live Nutrient Web SDK environment prefilled with the code. There’s no setup, no local project, and no fiddling. Just click, run, and start tinkering with the shortcuts handling code directly in your browser.
Whether you want to hijack Control-P, test Escape behavior, or block Delete keys, the Playground makes it effortless to see how each pattern behaves in a real viewer session.
The problem — The same keystroke can trigger listeners multiple times as events retarget across boundaries.
The solution — Attach exactly one listener in the capturing phase.
// Your viewer configuration.
const config = {
container: "#viewer",
document: "document.pdf",
// ... your other options.
};
NutrientViewer.load(config).then((instance) => {
// For Shadow DOM: use `document`.
// For iframe: use `instance.contentDocument`.
document.addEventListener(
"keydown",
(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "1") {
e.preventDefault();
instance.setViewState((v) =>
v.set("interactionMode", NutrientViewer.InteractionMode.INK),
);
}
},
true, // Capture phase.
);
});
When to use — This is your foundation. Start here, and add more shortcuts following the same pattern (Control-2, Control-3, etc.).
Tip — If the viewer is embedded in an iframe, use instance.contentDocument for event handling. Otherwise, use document. Always ensure you only attach listeners to one of these targets to avoid duplicate events.
Try this example in our Playground.
The problem — Your app has a custom Export PDF button, but users keep pressing Control-P and triggering the browser’s print dialog instead.
The solution — Intercept Control-P before either the browser or viewer sees it.
NutrientViewer.load(config).then(() => {
document.addEventListener(
"keydown",
(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "p") {
e.preventDefault(); // Stop browser print.
e.stopImmediatePropagation(); // Stop viewer print.
// Optional: Trigger your custom export logic here.
// handleCustomExport();
}
},
true,
);
});
When to use — When you need to replace standard shortcuts with custom workflows. Keep the logic focused by only blocking the specific keys you’re replacing.
Important — This overrides the default SDK behavior. Ensure you provide proper checks and equivalent functionality before removing standard shortcuts users expect.
Try this example in our Playground.
The problem — You show a confirmation modal over the viewer, and the user presses Escape, expecting the modal to close, but the viewer’s toolbar collapses instead.
The solution — Intercept Escape when your modal is active, and restore normal behavior when it closes.
NutrientViewer.load(config).then(() => {
let modalActive = false; // Set this to `true` when your modal opens.
const handleEscape = (e) => {
if (e.key === "Escape" && modalActive) {
e.stopImmediatePropagation(); // Viewer never hears it.
console.log("Close your modal here");
// modalActive = false; // Optional depending on your UI.
}
};
document.addEventListener("keydown", handleEscape, true);
// In your UI code:
// modalActive = true; // When opening the modal.
// modalActive = false; // When closing the modal.
});
When to use — Any time you layer a user interface (UI) on top of the viewer — modals, dialogs, overlays. This ensures your UI gets priority without permanently breaking the viewer’s escape behavior.
Key detail — Make sure to only hijack Escape when modalActive is true. This conditional approach means the viewer works normally the rest of the time.
Try this example in our Playground.
The problem — Users want to switch annotation tools via keyboard (like Photoshop’s tool shortcuts), but clicking a toolbar button steals focus from the document. Now they have to click back into the document before annotating.
The solution — Change the tool and immediately blur the active button so focus stays on the canvas.
NutrientViewer.load(config).then((instance) => {
const blurActiveBtn = () => {
instance.contentDocument
.querySelector(".NutrientViewer-Tool-Button-active")
?.blur();
};
document.addEventListener(
"keydown",
(e) => {
if (!e.ctrlKey && !e.metaKey) return;
switch (e.key) {
case "1":
e.preventDefault();
instance.setViewState((v) =>
v.set("interactionMode", NutrientViewer.InteractionMode.INK),
);
setTimeout(blurActiveBtn, 0);
break;
case "2":
e.preventDefault();
instance.setViewState((v) =>
v.set(
"interactionMode",
NutrientViewer.InteractionMode.SHAPE_RECTANGLE,
),
);
setTimeout(blurActiveBtn, 0);
break;
case "3":
e.preventDefault();
instance.setViewState((v) =>
v.set("interactionMode", NutrientViewer.InteractionMode.TEXT),
);
setTimeout(blurActiveBtn, 0);
break;
}
},
true,
);
});
When to use — For power users who annotate rapidly and want Photoshop-style tool switching. This pattern allows users to switch between annotation tools via keyboard shortcuts without losing focus on a document, streamlining workflows for reviewers who frequently cycle through tools.
Why setTimeout(..., 0)? — The viewer’s internal focus routine runs synchronously. By deferring the blur to the next event loop tick, this lets the viewer finish its focus logic first and then immediately unfocus. This is required for the pattern to work at all: The viewer runs its own synchronous focus logic, and deferring the blur with setTimeout(..., 0) ensures the viewer finishes that routine before removing focus.
Try this example in our Playground.
The problem — When you display a modal or overlay on top of the viewer, pressing Escape often collapses the viewer’s toolbar instead of closing your modal. Both your app and the viewer listen for the same key, and the viewer wins the race because its handler fires first. This can confuse users who expect Escape to dismiss what’s visually on top.
The solution —
NutrientViewer.load(config).then((instance) => {
instance.addEventListener("annotations.willChange", (e) => {
if (e.reason === "deleteStart") e.cancelAllow();
});
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
e.stopImmediatePropagation();
}
},
true,
);
});
When to use — In workflows where deletion requires authorization (e.g. only managers can delete comments, or deletions must be logged for compliance). This pattern blocks accidental keyboard deletions while keeping your custom Delete with Approval buttons functional.
Important — cancelAllow() blocks all deletion attempts — keyboard, toolbar, and API calls. Use it sparingly to avoid locking legitimate workflows. Moreover, to hide Delete buttons on the toolbar as well, here’s a CSS line you can add to take care of this in the Playground link below:
.PSPDFKit-Annotation-Toolbar [class*="-Toolbar-Button-Delete"] {
display: none;
}
Try this example in our Playground.
The problem — You’re showing a permissions dialog or payment confirmation and users must make a decision before continuing. But they can still scroll the document, zoom with keyboard shortcuts, or accidentally trigger annotations behind your modal.
The solution — Temporarily block everything — keyboard, mouse, wheel, gestures — and restore it all when the modal closes.
NutrientViewer.load(config).then((instance) => {
const getRoot = () =>
// Viewer root inside Shadow DOM or iframe.
instance.contentDocument?.querySelector(".PSPDFKit-Root") ||
// Fallback for non-shadow setups.
document.querySelector(".PSPDFKit-Root") ||
document;
let paused = false;
let cleanupFns = [];
let prevTouchAction = null;
// Stop any input reaching the viewer.
const kill = (ev) => {
ev.preventDefault();
ev.stopImmediatePropagation();
ev.stopPropagation();
};
function addListener(el, type, handler, opts) {
el.addEventListener(type, handler, opts);
cleanupFns.push(() => el.removeEventListener(type, handler, opts));
}
function pauseViewer() {
if (paused) return;
const root = getRoot();
// Block keyboard, mouse, wheel, and touch inside the viewer.
const events = [
"mousedown",
"mouseup",
"click",
"dblclick",
"contextmenu",
"pointerdown",
"pointermove",
"pointerup",
"dragstart",
"keydown",
"keyup",
"keypress",
"wheel",
"touchstart",
"touchmove",
"touchend",
];
events.forEach((event) => {
addListener(root, event, kill, true); // Capture phase.
});
// Disable pinch-zoom and panning gestures.
if (root instanceof HTMLElement) {
prevTouchAction = root.style.touchAction;
root.style.touchAction = "none";
cleanupFns.push(() => {
root.style.touchAction = prevTouchAction ?? "";
prevTouchAction = null;
});
}
paused = true;
}
function resumeViewer() {
if (!paused) return;
for (let i = cleanupFns.length - 1; i >= 0; i--) {
try {
cleanupFns[i]();
} catch {}
}
cleanupFns = [];
paused = false;
}
// Optional helpers your modal can call.
let criticalModalOpen = false;
function showCriticalModal() {
criticalModalOpen = true;
pauseViewer();
}
function closeCriticalModal() {
criticalModalOpen = false;
resumeViewer();
}
// Allow Escape to close your modal if needed.
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape" && criticalModalOpen) {
closeCriticalModal();
}
},
true,
);
// Prevent browser-level zoom (Control/Command + / - / =) while critical UI is active.
document.addEventListener(
"keydown",
(e) => {
if (!criticalModalOpen) return;
const isZoomShortcut =
(e.metaKey || e.ctrlKey) &&
(e.key === "+" || e.key === "=" || e.key === "-");
if (isZoomShortcut) {
e.preventDefault();
}
},
true,
);
});
When to use — For truly critical UIs that demand user attention, such as payment confirmations, destructive action warnings, and permission requests. This pattern is absolute: It shuts down all input. Use it sparingly.
Why this works — By attaching kill handlers at the root, document, and window levels in the capturing phase, you’re creating a three-layer shield, and nothing gets through. Setting touch-action: none disables built-in browser gestures like pinch-zoom or panning; this ensures touch and pointer events are entirely controlled by your handlers instead of the browser’s gesture recognizer.
Critical reminder — Remember to call resumeViewer() when closing your modal. Forgetting this leaves your viewer permanently frozen.
If your shortcuts aren’t working, follow this checklist.
true as the third parameter in addEventListener.preventDefault()? Default actions will still fire without it.document and contentDocument? Pick one to avoid duplicates.instance.contentDocument (or the shadow root) becomes available once the viewer has loaded. Attach viewer-specific shortcuts there when the intent is to affect only the viewer.Key principle — Always clean up listeners when your component unmounts. Orphaned listeners cause memory leaks and unpredictable behavior.
Understanding event capture and how embedding model boundaries affect event flow gives you complete control over keyboard interactions in Nutrient Web SDK. These six patterns cover the most common scenarios, but the principles extend to any custom keyboard behavior you need.
Five key takeaways:
true parameter) for reliable keyboard shortcuts.document or contentDocument) to avoid duplicates.Whether you’re building power user annotation workflows or custom modals, or you just need Control-1 to switch to the ink tool, these patterns provide a solid foundation that’s predictable, conflict-free, and easy to extend.
Have questions or unique keyboard handling challenges? Reach out to our Support team.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。