React to the viewer with events, not timers
Everything your interface needs to mirror is announced. Subscribe once, keep the unsubscribe or an
AbortSignal, and drop the polling loop.Basicsviewer.on(){ once, signal }AbortControllerdocumentChangedannotationsChangedpageChangedKernstra.events.on()licenseChanged
There are two emitters, and subscribing to the wrong one is silent. Document lifecycle events are global and fire on
Kernstra.events; everything else is per-viewer and fires on viewer.on(...). A handler registered on the wrong emitter never runs and never warns, so each emitter is typed by its own map (GlobalEventMap, ViewerEventMap) and the mistake is a compile error.1. Subscribe, and keep the unsubscribe
on() returns the unsubscribe. options.once hears the next emit only; options.signal unsubscribes when an AbortSignal aborts, so one AbortController can end every subscription a component made (a signal already aborted subscribes nothing). viewer.off(event, handler) with the same handler reference works too.state.tsTypeScript
const stop = viewer.on('pageChanged', ({ pageIndex }) => setCurrentPage(pageIndex)); stop(); const controller = new AbortController(); viewer.on('zoomChanged', ({ scale }) => setZoom(scale), { signal: controller.signal }); viewer.on('documentChanged', () => markDirty(), { signal: controller.signal }); viewer.on('exportCompleted', ({ sizeBytes }) => toast(sizeBytes), { once: true }); controller.abort(); // both gone
2. Mirror the document, not the DOM
pageChanged carries { pageIndex } when the current page changed by scrolling, navigation, a link, or a reload that left fewer pages than the one shown; not by a zoom. documentChanged says that the document changed, with source, tool, hasUnsavedChanges, canUndo and canRedo. annotationsChanged says what: { pageIndex, changes }, each change { type: 'added' | 'modified' | 'deleted', id } where id is what doc.annotations.get(id) takes; it needs the annotations feature and is not fired for the annotations a document opens with.state.tsTypeScript
viewer.on('pageChanged', ({ pageIndex }) => pager.set(pageIndex + 1)); viewer.on('documentChanged', ({ source, hasUnsavedChanges, canUndo, canRedo }) => { saveButton.disabled = !hasUnsavedChanges; undoButton.disabled = !canUndo; redoButton.disabled = !canRedo; log(`changed by ${source}`); }); viewer.on('annotationsChanged', async ({ pageIndex, changes }) => { for (const { type, id } of changes) { if (type === 'deleted') audit.remove(id); else audit.put(await doc.annotations.get(id), pageIndex); } });
A form field reports every commit of its value, each keystroke, on
documentChanged as on formFieldChanged: debounce anything expensive.3. The global emitter
Kernstra.events.on(event, handler, options?) takes the same options. licenseChanged carries a LicenseState: a valid license fills env, plan, features, offline and, when it has one, expiresAt; an invalid one fills reason (and pauseReason when paused). documentLoaded fires for every factory with { document, pageCount, hasPassword }, so a host with several documents knows which; documentClosed carries the document, closed by then: compare it, never call it. Kernstra.events offers on and off only: the SDK alone emits.app.tsTypeScript
import { Kernstra } from '@kernstra/pdf-web-sdk'; // Kept across Kernstra.destroy(): subscribe once, at module level if you like. Kernstra.events.on('licenseChanged', (state) => { if (state.valid) { exportButton.hidden = !state.features.includes('export'); } else { banner.textContent = state.reason; } }); Kernstra.events.on('documentLoaded', ({ pageCount }) => setPageCount(pageCount));
Gotchas
- A handler that throws is logged and does not stop the others.
documentLoadedis not fired when the viewer reloads the document after an edit or an undo.- Callbacks decide; events observe. A callback (
callbacks.onDownload,onConfirm, …) is where the SDK hands you a decision or a job of its own; everything that merely happened is an event, so any number of parts of your page can listen, and each can stop on its own. - Nothing reports
hasUnsavedChangesgoing back tofalse: the export'sexportCompleted, or your ownmarkSaved(), is that moment. - Under a license without
extract, theFormFieldInfosnapshot thatformFieldChangedcarries hasvalueset to'';documentChangedcarries no document content, so there is nothing to blank.