Changes, undo and previews

How an Athanor plugin edits the page by sending a change, which ops a change can carry, how undo and the safety screen work, and how to propose edits.

The rule

A plugin never edits the document directly. It reads a snapshot of the page with athanor.snapshot(), and it writes by sending a change: a label and a list of ops.

await athanor.apply({
  label: 'Add ethanol',
  ops: [{ op: 'addStructure', smiles: 'CCO', name: 'Ethanol' }],
});

Athanor does the rest, in this order:

  1. Checks the plugin holds Edit the document.
  2. Checks the change's shape: the op names, the types, the caps below.
  3. Runs the safety screen on every structure the change adds. A refused structure stops the whole change, with the screen's own message.
  4. Applies the ops one by one.
  5. Runs the safety screen again on any structure an op changed (an atom turned into another element can make a different molecule).
  6. If anything was refused, rolls the page back to exactly how it was. Nothing goes on the redo stack, and the promise rejects with a message naming the op: "Op 2: it names an atom that is no longer on the page. Nothing on the page changed."
  7. Otherwise makes the whole change one undo step, named "Plugin: label".

apply resolves with { label, added }: the undo step's name, and the ids of what the change added, in order.

The ops

Op Fields What it does
addStructure smiles or molfile (exactly one), optional x, y, name Adds a structure. Without x and y, Athanor places it in a row to the right of everything already on the page. Adds "s:<id>" to added
addText text, x, y, optional role Adds a text block. role is caption (the default), conditions, title or compoundLabel. Adds "n:<id>" to added
setStyle id (a structure), color Colors a structure. color is #rrggbb, or "" for the default ink
setName structureId, name Names a structure
setAtom id (an atom), optional element, charge Changes an atom's element (a symbol such as N or Cl) or charge (a whole number from -8 to 8)
setBond id (a bond), order and/or stereo order is 1, 2 or 3. stereo is none, wedge or hash; the narrow end is at the bond's first atom (a in the snapshot)
remove ids Removes atoms (numbers), structures ("s:<id>") and text blocks ("n:<id>")

Ids

The snapshot gives every object an id:

  • Atoms and bonds are numbers.
  • Structures are "s:<first atom id>". A structure id is stable until the structure is edited, so read a fresh snapshot after a change rather than reusing old ids.
  • Text blocks are "n:<id>".

An op that names something no longer on the page is refused, and so is the whole change.

Limits

Limit Value
Ops in one change 500
Structures added by one change 200
SMILES length 2,000 characters, no spaces
MOL file size 200,000 characters
Text in addText 1 to 1,000 characters
Coordinates Finite numbers no larger than 1,000,000 either way
label Up to 60 characters; "Edit" when missing
name Up to 120 characters
Changes per second 5 (20 for official plugins). Past it: "Too many changes at once. Wait a moment."

Undo

Each athanor.apply is exactly one undo step, however many ops it carries. So build one change for one action. Ten apply calls for one button press leave ten undo steps, and each one counts toward the rate limit.

Proposing instead of applying

athanor.propose(change, { title, detail }) makes the same change but asks the person first. The change appears on the page with a box that names your plugin ("Plugin proposes"), your title (up to 80 characters) and detail (up to 200), and Accept and Reject.

const answer = await athanor.propose(
  { label: 'Wedge 2 stereocenters', ops },
  { title: 'Wedges for 2 stereocenters', detail: 'Accept to keep them.' },
);
if (!answer.accepted) {
  // The person rejected it, and the page is back as it was.
}
  • The promise resolves when the person answers, with { accepted: true } or { accepted: false }.
  • Reject takes the change back exactly while it is still the last edit. If the person has drawn on top of it, it stays, accepted is true, and Undo is the way back.
  • One proposal per plugin at a time. A new proposal rejects the one before it.
  • propose needs Edit the document, like apply, and goes through the same checks.

This example, from Athanor's stereocenter example plugin, proposes wedges first and hashes if the person rejects them:

athanor.commands.on('stereo', async () => {
  const page = await athanor.snapshot();
  const ops = [];
  for (const s of page.structures) {
    for (const c of s.stereocenters.filter((x) => x.label === null)) {
      // A wedge's narrow end is the bond's first atom: only a bond that starts at the center can say it.
      const b = s.bonds.find((x) => x.a === c.atom && x.order === 1);
      if (b) ops.push({ op: 'setBond', id: b.id, stereo: 'wedge' });
    }
  }
  if (!ops.length) { athanor.notify('No undefined stereocenter here can take a wedge from its own end.'); return; }
  const n = ops.length + ' stereocenter' + (ops.length === 1 ? '' : 's');
  const first = await athanor.propose({ label: 'Wedge ' + n, ops }, { title: 'Wedges for ' + n, detail: 'Accept to keep them. Reject, and the other choice (hashes) is proposed next.' });
  if (first.accepted) return;
  await athanor.propose({ label: 'Hash ' + n, ops: ops.map((o) => ({ ...o, stereo: 'hash' })) }, { title: 'Hashes for ' + n, detail: 'The other configuration. Reject to leave them undefined.' });
});

Reading the page

athanor.snapshot() needs Read the open document. It resolves with:

{
  apiVersion: 1,
  title: 'Untitled',            // the document's title
  structures: [{
    id: 's:12', smiles: 'CCO', molfile: '...', formula: 'C2H6O', mw: 46.069, name: 'Ethanol',
    atoms: [{ id: 12, element: 'C', x: 100, y: 200, charge: 0 } /* ... */],
    bonds: [{ id: 30, a: 12, b: 13, order: 1 } /* ... */],
    box: { minX: 100, minY: 180, maxX: 160, maxY: 200 },
    selected: false,
    stereocenters: [{ atom: 14, label: 'R' }],   // label is null when no wedge says which
  }],
  texts: [{ id: 'n:3', text: 'rt, 2 h', x: 300, y: 220 }],
  selection: { structures: [], atoms: [] },
}

Coordinates are in the page's own units, the same ones addStructure and addText take. The full shape is in the API reference.

Not in API 1

These are not ops in API 1, and a change that uses them is refused with "is not an op API 1 knows": replacing a structure in place, isotopes and atom labels, data stored on objects, and relaunch buttons. Remove and add instead of replacing.

Is this helpful?

Last updated Nov 11, 2026