Web SDKv1.0.0

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.
  • documentLoaded is 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 hasUnsavedChanges going back to false: the export's exportCompleted, or your own markSaved(), is that moment.
  • Under a license without extract, the FormFieldInfo snapshot that formFieldChanged carries has value set to ''; documentChanged carries no document content, so there is nothing to blank.