EmbedPDF

Changes & undo

A change is one user action: the writes it makes, applied together, all or none. Every change can be undone, and so can every annotation, form or metadata call you make on its own.

import { toFieldRef } from '@embedpdf/engine';
 
const approved = await doc.apply({
  ops: [
    { type: 'annotations.update', ref: square.ref, patch: { color: '#16a34a' } },
    { type: 'forms.setValue', field: toFieldRef('status'), value: { value: 'Approved' } },
  ],
});
 
// Both writes go back:
await doc.apply({ undoOf: approved.meta.opId });

Apply a change#

apply() takes a list of ops. Each op is one of the calls you already know, written as data: a type, and the arguments the call takes, by name. The engine applies them in order:

const result = await doc.apply({
  ops: [
    {
      type: 'annotations.create',
      page: page.ref,
      data: { subtype: 'square', box: { x: 72, y: 92, width: 200, height: 100 } },
    },
    { type: 'annotations.delete', ref: oldSquare.ref },
    { type: 'metadata.update', patch: { modifiedAt: new Date() } },
  ],
});

When one op fails, none of them is written, no event fires, and apply() rejects with that op’s error. The document is as it was before the call.

Op typeIts fieldsThe call it stands for
annotations.createpage, data, objectNumber, resourcespage.annotations.create(data, { objectNumber, resources })
annotations.updateref, patch, resources, expectpage.annotations.update(ref, patch, { resources })
annotations.deleteref, expectpage.annotations.delete(ref)
annotations.reorderpage, refs, positionpage.annotations.reorder(refs, position)
forms.setValuefield, value, expectdoc.forms.setValue(field, value)
forms.resetfieldsdoc.forms.reset(fields)
forms.createdraft, objectNumber, widgetObjectNumbersdoc.forms.create(draft, { objectNumber, widgetObjectNumbers })
forms.updatefield, patch, expectdoc.forms.update(field, patch)
forms.deletefield, expectdoc.forms.delete(field)
forms.addWidgetfield, placement, objectNumber, splitObjectNumberdoc.forms.addWidget(field, placement, { objectNumber, splitObjectNumber })
forms.removeWidgetfield, widgetdoc.forms.removeWidget(field, widget)
forms.deleteWidgetwidget, expectdoc.forms.deleteWidget(widget)
forms.updateWidgetwidget, patch, expectdoc.forms.updateWidget(widget, patch)
forms.reorderWidgetspage, widgets, positiondoc.forms.reorderWidgets(widgets, position)
forms.reorderCalculationsfields, positiondoc.forms.reorderCalculations(fields, position)
forms.setSignatureAppearancefield, appearancedoc.forms.setSignatureAppearance(field, appearance)
forms.setDisplayfield, displayShows or hides a field’s widgets, as a form script does
forms.setAppearanceTextfield, textSets the text a field shows without changing its value, as a format script does
metadata.updatepatch, expectdoc.metadata.update(patch)
metadata.updateCustompatchdoc.metadata.custom.update(patch)

Put everything one action does into one change: it’s one step in the user’s undo history, and its events arrive together.

What comes back#

result.items has one item per op, in op order. Each item is what the op’s call returns, with the op’s type added (and the page, for annotation ops). result.meta is about the whole change:

const created = result.items[0];
if (created.type === 'annotations.create') select(created.annotation.ref);
 
result.meta.opId; // the change's name: what undo takes
result.meta.undoable; // true
FieldWhat it is
meta.opIdThe change’s opId: the one you passed, or one made up for it.
meta.undoableWhether { undoOf: opId } can undo it. See What can’t be undone.
meta.affectedPagesEvery page whose annotations the change touched.

A change with no ops writes nothing: it resolves at once, with no items, and undoable: false.

Refer to something an earlier op makes#

A later op can work on what an earlier op of the same change creates. Take the new object’s number from the document’s pool first, so its ref is known before anything is written:

await doc.objectNumbers.reserve(1);
const objectNumber = doc.objectNumbers.take()!;
const note = { kind: 'objectNumber', page: page.ref, objectNumber } as const;
const rect = { x: 500, y: 72, width: 20, height: 20 };
 
await doc.apply({
  ops: [
    {
      type: 'annotations.create',
      page: page.ref,
      data: { subtype: 'text', rect, contents: 'Is this total right?' },
      objectNumber,
    },
    {
      type: 'annotations.create',
      page: page.ref,
      data: { subtype: 'text', rect, contents: 'Asking finance.', reply: { to: note } },
    },
  ],
});

Undo this change and the note and its reply go together. Object numbers are explained in Refs & identity.

Undo and redo#

Undo a change by its opId:

const undo = await doc.apply({ undoOf: approved.meta.opId });

That works for every change, and for every call the table above lists, made on its own: each returns its opId in meta, the way a change does.

const { meta } = await page.annotations.update(ref, { contents: 'Seen' });
await doc.apply({ undoOf: meta.opId });

An undo is a change of its own, with its own opId. Undo the undo, and the change applies again: that’s redo.

await doc.apply({ undoOf: undo.meta.opId }); // redo

An undo button needs two lists of opIds:

import type { Change } from '@embedpdf/engine';
 
const done: string[] = [];
const undone: string[] = [];
 
async function act(change: Change) {
  const { meta } = await doc.apply(change);
  done.push(meta.opId);
  undone.length = 0;
}
 
async function undo() {
  const opId = done.pop();
  if (opId) undone.push((await doc.apply({ undoOf: opId })).meta.opId);
}
 
async function redo() {
  const opId = undone.pop();
  if (opId) done.push((await doc.apply({ undoOf: opId })).meta.opId);
}

Undo works through the engine, not on a copy you keep: it reads the document as it is now, and puts back what the change wrote.

When the document changed since#

An undo puts back only what still shows what the change set. Someone may have changed the same thing since: another user on the cloud, or your own later change. What they changed is left as they left it:

  • A field someone changed since keeps its new value. The item names it in skipped, such as skipped: ['color'].
  • An op whose annotation or field is gone, or was changed all over, is left alone entirely. Its item is { type: 'skipped', op }, with op the type of item it would have been.
  • A create isn’t undone when someone changed what it made, or replied to it.
  • A reorder puts each annotation or widget back next to the neighbours it had, unless it was moved since or the one below it changed. Neighbours that are gone are skipped over.

Nothing is written for what’s left alone, and no event fires for it. Tell the user when it happened:

const undo = await doc.apply({ undoOf: opId });
const leftAlone = undo.items.some((item) => item.type === 'skipped' || 'skipped' in item);
if (leftAlone) showToast('Some of this was changed since, and was kept.');

It goes by value: a field changed and then set back to what your change set counts as unchanged, and is undone. Undoing the same change twice is harmless: the second undo finds nothing left to put back.

Undoing a delete brings back what was deleted as it was: the same refs, its place among the page’s annotations, its replies and popup, and who wrote it. The item is annotations.restore, with the annotations that came back, forms.restore, with the field, or forms.restoreWidget, with the widget back at its place and in its field.

What can’t be undone#

meta.undoable says whether a write can be undone. It’s false for:

  • A write that changed nothing, such as a change with no ops, a reset of fields already at their defaults, or hiding a field that’s already hidden.
  • Writes a change can’t hold: page edits, attachments, form data imports. They’re writes of their own. An annotation import is a change and can be undone.
  • Final writes: applying redactions, flattening, signing and repairing a form. They take content out of the document or seal it, so there’s nothing to go back to.

A final write also ends undo for every change before it. Undoing one of those fails with UndoUnavailable and details.reason: 'final-change'. Changes after the final write can be undone as usual.

import { EngineError, EngineErrorCode } from '@embedpdf/engine';
 
try {
  await doc.apply({ undoOf: opId });
} catch (error) {
  if (EngineError.is(error, EngineErrorCode.UndoUnavailable)) {
    showToast('This can no longer be undone.');
  } else {
    throw error;
  }
}

Guard a change with expect#

By default, a change writes over whatever is there: the last write wins. When your change only makes sense if the document still holds what the user saw, say so with expect. It takes the same shape as what the op writes:

await doc.apply({
  ops: [
    {
      type: 'annotations.update',
      ref: square.ref,
      patch: { color: '#16a34a' },
      expect: { color: '#e11d48' }, // only while it's still red
    },
  ],
});

When the document doesn’t match, the whole change is refused with ChangeConflict, and nothing is written. error.details.opIndex is the op that didn’t match and error.details.fields the fields that differed. annotations.delete and forms.delete expect the values the thing has before it goes. A reorder takes no expect: its position names a neighbour, which is already what it assumes.

Asking again#

The opId names the change. Asked again with the same opId, a change isn’t applied a second time: you get the first answer back, and no event fires again. A change that was refused answers its refusal again, even if it would now apply, so a retry can never land after something that came later.

const opId = crypto.randomUUID();
const first = await doc.apply(change, { opId });
const again = await doc.apply(change, { opId }); // the same result; nothing written

A different change under an opId that already has an answer fails with IdempotencyKeyReused. Use a fresh opId for every change. See Events for what an opId may contain.

Who may undo#

Only the user who made a change can undo it; anyone else gets Forbidden. An undo is also checked against what the user may do now, like any write: a user who can no longer change annotations can’t undo an annotation change either. A change needs, op by op, the permissions each call needs; see Permissions.

How long undo lasts#

The engine keeps what undo needs while the document is open, in this tab: the last 4,096 changes, and up to 64 MB of the objects they replaced. When it has to let go of the oldest, undoing them fails with UndoUnavailable and details.reason: 'expired'. Closing the document ends its undo history.

Good to know#

Events#

A change fires the events its ops’ calls fire, in op order, all with the change’s opId in origin.tx. An undo fires what it did: annotations.updated for a field put back, annotations.deleted for a create taken back, annotations.restored and forms.restored for what came back. Its events carry origin.undoOf, the opId of the change it undid. See Events.

How the engine puts things back#

When a change runs, the engine keeps a copy of everything it’s about to replace or delete. When nothing touched those objects since, an undo puts the copies back exactly, down to the annotation’s appearance. When something did, it falls back to putting back each field that still holds the value the change wrote.

Was this page helpful?

Your feedback goes directly to the documentation team.