EmbedPDF

Events, saving & sharing

Every annotation change fires an event, whoever made it: the user, your code, or someone else on a shared document. Listen for them to keep your own UI or server in step:

events.tsx
import { useAnnotationEvent } from '@embedpdf/react/annotation';

export function ActivityLog() {
  useAnnotationEvent(
    (annotation) => annotation.onCreated,
    ({ annotation, origin }) => {
      console.log(`${annotation.author} added a ${annotation.subtype}`);
    },
  );

  return null;
}

Loading live preview…

import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import {
  AnnotationLayer,
  annotationPlugin,
  useAnnotation,
  useAnnotationEvent,
  useAnnotationState,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';

import './events.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

interface Entry {
  id: number;
  text: string;
  origin: string;
}

// Every change, once the engine has saved it, newest first.
function ActivityLog() {
  const [entries, setEntries] = useState<Entry[]>([]);
  const nextId = useRef(0);
  const log = (text: string, origin: string) =>
    setEntries((current) => [{ id: nextId.current++, text, origin }, ...current].slice(0, 30));

  useAnnotationEvent(
    (annotation) => annotation.onCreated,
    ({ annotation, origin }) =>
      log(`${annotation.author ?? 'Someone'} added a ${annotation.subtype}`, origin.kind),
  );
  useAnnotationEvent(
    (annotation) => annotation.onUpdated,
    ({ annotation, origin }) => log(`Changed a ${annotation.subtype}`, origin.kind),
  );
  useAnnotationEvent(
    (annotation) => annotation.onDeleted,
    ({ refs, origin }) =>
      log(
        `Deleted ${refs.length === 1 ? 'one annotation' : `${refs.length} annotations`}`,
        origin.kind,
      ),
  );
  useAnnotationEvent(
    (annotation) => annotation.onReordered,
    ({ order, origin }) => log(`Restacked ${order.length} on a page`, origin.kind),
  );

  return (
    <ol className="panel log">
      {entries.length === 0 && <li className="empty">Nothing yet</li>}
      {entries.map((entry) => (
        <li key={entry.id} className="entry">
          <span>{entry.text}</span>
          <span className="origin">{entry.origin}</span>
        </li>
      ))}
    </ol>
  );
}

const TOOLS = [
  { id: 'pointer', label: 'Select' },
  { id: 'square', label: 'Rectangle' },
  { id: 'ink', label: 'Pen' },
];

function Toolbar() {
  const annotation = useAnnotation();
  const interaction = useInteraction();
  const { activeToolId } = useInteractionState();
  const { selected } = useAnnotationState();

  return (
    <div className="toolbar">
      <div className="segmented" role="group" aria-label="Tool">
        {TOOLS.map((tool) => (
          <button
            key={tool.id}
            type="button"
            aria-pressed={activeToolId === tool.id}
            onClick={() => interaction.activateTool(tool.id)}
          >
            {tool.label}
          </button>
        ))}
      </div>
      <button
        type="button"
        className="button"
        disabled={selected.length === 0}
        onClick={() => annotation.selection.delete()}
      >
        Delete
      </button>
    </div>
  );
}

// On load: a rectangle on the cover, so the log starts with its event.
function AddRectangle() {
  const annotation = useAnnotation();
  const ready = useAnnotationState((state) => state.status === 'ready');
  const cover = usePageList()[0]?.ref;
  const added = useRef(false);

  useEffect(() => {
    if (!ready || !cover || added.current) return;
    added.current = true;
    void annotation.create(cover, {
      subtype: 'square',
      box: { x: 96, y: 506, width: 178, height: 54 },
      color: '#e5484d',
      strokeWidth: 3,
    });
  }, [annotation, ready, cover]);

  return null;
}

export default function App() {
  return (
    <Viewer
      engine={engine}
      plugins={plugins}
      identity={{ userId: 'u_381', displayName: 'Dana Smith' }}
      initialDocuments={[{ source: ebook }]}
    >
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddRectangle />
        <Toolbar />
        <div className="viewer">
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <AnnotationLayer />
              </>
            )}
          </Stage>
          <ActivityLog />
        </div>
      </DocumentGate>
    </Viewer>
  );
}

Try it: draw, move and delete. Each change is logged once the engine has saved it, as local: this viewer made it.

The hook listens while the component is mounted. Outside a component, call the event on the plugin’s API; it returns a function that stops listening:

const stop = annotation.onCreated(({ annotation }) => {
  /* … */
});

Events#

EventFires whenCarries
onCreatedAn annotation was added.annotation, origin
onUpdatedAn annotation was changed.annotation, origin
onDeletedAnnotations were deleted: one, plus its popup and replies.refs, page, origin
onReorderedThe drawing order on a page changed.page, order, origin
onResyncedAnnotations were read again, as after loading a document.pages
onWriteFailedThe engine refused a change the user made.refs, error
onSettingsChangedA setting changed, through updateSettings() or resetSettings().settings, changed
  • Once, and after it’s saved. onCreated, onUpdated, onDeleted and onReordered fire when the engine has saved the change, never while it’s still pending. A change fires once, however many screens show it.
  • Loading isn’t a list of creates. When a document opens, its annotations arrive as one onResynced, not as an onCreated per annotation. The same happens after a change that touches whole pages, such as flattening or redaction. Then read the pages it names again with list().
  • annotation is the record after the change. It’s what get() returns now.
  • order is the whole page. After a reorder, order holds the page’s annotations as refs, bottom to top, the same order list({ pages: [page] }) returns.

The selection, a shape being drawn, text editing and hover have events of their own (onSelectionChanged, onDraftChanged, onEditingChanged, onHoverChanged); see Selecting & editing. A tool’s defaults have tools.onDefaultsChanged; see Tools & defaults.

Where a change came from#

origin says who made the change:

FieldWhat it is
origin.kind'local' when this viewer made the change, 'remote' when someone else did.
origin.subThe user who made it (on a shared document).
origin.tsWhen it happened, in milliseconds.
origin.txShared by every change one call made, such as an import.
remote-toast.tsx
import { useAnnotationEvent } from '@embedpdf/react/annotation';
import { toast } from './toast'; // your own

export function RemoteChanges() {
  useAnnotationEvent(
    (annotation) => annotation.onUpdated,
    ({ annotation, origin }) => {
      if (origin.kind === 'remote') toast(`${annotation.author} changed a comment`);
    },
  );

  return null;
}

These are the engine’s own events, so the fields mean the same as on Events.

Save the PDF#

Annotations are part of the document. Save the document and they’re in the file:

const bytes = await documents.download(); // the whole PDF, with every annotation

Loading live preview…

import { useEffect, useRef } from 'react';
import {
  Viewer,
  DocumentGate,
  saveFile,
  useDocument,
  useDocuments,
  usePageList,
} from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import {
  AnnotationLayer,
  annotationPlugin,
  useAnnotation,
  useAnnotationState,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';

import './download.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// On load: a rectangle on the cover, so there's a change to save.
function AddRectangle() {
  const annotation = useAnnotation();
  const ready = useAnnotationState((state) => state.status === 'ready');
  const cover = usePageList()[0]?.ref;
  const added = useRef(false);

  useEffect(() => {
    if (!ready || !cover || added.current) return;
    added.current = true;
    void annotation.create(cover, {
      subtype: 'square',
      box: { x: 96, y: 506, width: 178, height: 54 },
      color: '#e5484d',
      strokeWidth: 3,
    });
  }, [annotation, ready, cover]);

  return null;
}

// The annotations are part of the document: the downloaded PDF has every one.
function SaveBar() {
  const documents = useDocuments();
  const interaction = useInteraction();
  const { activeToolId } = useInteractionState();
  const { hasUnsavedChanges } = useDocument();

  return (
    <div className="toolbar">
      <button
        type="button"
        className="button"
        aria-pressed={activeToolId === 'ink'}
        onClick={() => interaction.activateTool(activeToolId === 'ink' ? 'pointer' : 'ink')}
      >
        Pen
      </button>
      <button
        type="button"
        className="button"
        onClick={async () => saveFile(await documents.download(), 'ebook-annotated.pdf')}
      >
        Download the PDF
      </button>
      <span className="spacer" />
      <output className="readout">{hasUnsavedChanges ? 'Unsaved changes' : 'All saved'}</output>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddRectangle />
        <SaveBar />
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <AnnotationLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

A save waits for pending changes, so what you save is what the user sees. For the difference between a quick save that appends the changes and a full rewrite, see Saving.

Export and import#

To take annotations out of a document, as data rather than inside a PDF, export them. One call gives you a bundle: every annotation with the bytes it carries (a stamp’s drawing, an attached file). One call puts a bundle into a document. AnnotationTransfer comes from @embedpdf/react/annotation:

const bundle = await annotation.export(); // everything
const text = AnnotationTransfer.stringify(bundle); // one JSON text, ready to store
 
// later, in the same document or another one
const { annotations, dropped } = await annotation.import(await AnnotationTransfer.parse(text), {
  pages: 'by-position',
});

Use it to:

  • keep annotations in your own database, apart from the PDF;
  • carry a review over to a new version of a document;
  • let a user paste annotations from one document into another.

Loading live preview…

import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import {
  AnnotationLayer,
  AnnotationTransfer,
  annotationPlugin,
  useAnnotation,
  useAnnotationList,
  useAnnotationState,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';

import './export-import.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The bundle as one JSON text: what you'd store in your own database.
function Transfer() {
  const annotation = useAnnotation();
  const ready = useAnnotationState((state) => state.status === 'ready');
  const cover = usePageList()[0]?.ref;
  const count = useAnnotationList().length;
  const [text, setText] = useState('');
  const [status, setStatus] = useState('');
  const started = useRef(false);

  const exportAll = async () => {
    const bundle = await annotation.export(); // everything
    setText(AnnotationTransfer.stringify(bundle));
    setStatus(`Exported ${bundle.items.length}`);
  };

  const deleteAll = async () => {
    await Promise.all(annotation.list().map((each) => annotation.delete(each.ref)));
    setStatus('Deleted them all');
  };

  const importText = async () => {
    try {
      const { annotations, dropped } = await annotation.import(
        await AnnotationTransfer.parse(text),
      );
      setStatus(`Imported ${annotations.length}, left out ${dropped.length}`);
    } catch (error) {
      setStatus(error instanceof Error ? error.message : String(error));
    }
  };

  // On load: a note and a rectangle on the cover, exported at once.
  useEffect(() => {
    if (!ready || !cover || started.current) return;
    started.current = true;
    void Promise.all([
      annotation.create(cover, {
        subtype: 'text',
        rect: { x: 470, y: 232, width: 20, height: 20 },
        contents: 'Can we shorten the title?',
        color: '#facc15',
      }),
      annotation.create(cover, {
        subtype: 'square',
        box: { x: 96, y: 506, width: 178, height: 54 },
        color: '#e5484d',
        strokeWidth: 3,
      }),
    ]).then(exportAll);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, cover]);

  return (
    <div className="panel transfer">
      <div className="toolbar">
        <button type="button" className="button" onClick={() => void exportAll()}>
          Export
        </button>
        <button
          type="button"
          className="button"
          disabled={count === 0}
          onClick={() => void deleteAll()}
        >
          Delete all
        </button>
        <button
          type="button"
          className="button"
          disabled={text === ''}
          onClick={() => void importText()}
        >
          Import
        </button>
      </div>
      <output className="readout">{status}</output>
      <textarea
        className="bundle"
        aria-label="The exported bundle"
        spellCheck={false}
        value={text}
        onChange={(event) => setText(event.target.value)}
      />
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="viewer">
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <AnnotationLayer />
              </>
            )}
          </Stage>
          <Transfer />
        </div>
      </DocumentGate>
    </Viewer>
  );
}

Try it: Delete all, then Import: they come back from the text. Import again: the annotations with a name are left out, since they’re there already.

Export waits for pending changes, and imported annotations show at once, like any other change.

What to export:

await annotation.export({ refs: annotation.selection.list().map((a) => a.ref) }); // the selection
await annotation.export({ pages: [page.ref] }); // one page

A selection brings what it needs: a reply brings the comment it answers, and a comment brings its replies.

How to import:

OptionWhat it doesDefault
pagesWhere each page’s annotations go: 'same' (the same page), 'by-position', or a list of pairs.'same'
attribution'restore' keeps who wrote each annotation, 'stamp' makes the importing user the author.'restore'

Use 'stamp' when a user pastes annotations: they become theirs. 'restore' writes annotations in other people’s names, so it needs a special permission; keep it for trusted jobs, such as loading from your own database.

The result tells you what happened:

  • annotations: the new annotations, in bundle order.
  • refMap: each annotation’s ref in the bundle, and its new ref here.
  • dropped: what was left out, and why. For example, an annotation whose name is already taken on its page. Importing the same bundle twice doesn’t duplicate annotations with a name.

The engine’s Import & export page has every option, reason and limit.

Keep annotations in your own database#

There are two ways to store annotations outside the PDF.

Store a bundle per document. Export when the user saves, store the text, and import it when the document opens again. It’s one row per document and one call each way:

// on save
await api.put(`/reviews/${docId}`, AnnotationTransfer.stringify(await annotation.export()));
 
// on open, once the annotations are loaded (status 'ready')
const text = await api.get(`/reviews/${docId}`);
if (text) await annotation.import(await AnnotationTransfer.parse(text));

Store each annotation as it changes. Listen for the events and write each change to your server. Key your rows by the annotation’s ref:

sync.tsx
import { annotationKey, useAnnotationEvent } from '@embedpdf/react/annotation';
import { api } from './api'; // your own

export function SyncToServer() {
  useAnnotationEvent(
    (annotation) => annotation.onCreated,
    ({ annotation, origin }) => {
      if (origin.kind === 'local')
        api.put(`/annotations/${annotationKey(annotation.ref)}`, annotation);
    },
  );

  return null;
}

Handle onUpdated the same way, and onDeleted by deleting each of its refs. On onResynced, compare the pages it names with list(). Only send local changes: a remote one was already saved by whoever made it.

Which id to store#

Store the annotation’s ref. It’s plain data, and it stays valid after the document is saved and opened again. To use it as a key in a map or a URL, turn it into a string with annotationKey(ref). See Refs & identity.

Good to know#

Unsaved changes#

To warn before someone leaves with annotations that aren’t downloaded yet, use hasUnsavedChanges from useDocument(); see Warn before changes are lost.

Events on a shared document#

The local engine’s events come from this tab: the changes made here, by the user or your code.

Was this page helpful?

Your feedback goes directly to the documentation team.