Skip to content

Events ​

The editor communicates with your application through callback functions passed in the configuration.

Content Events ​

onChange ​

Called whenever the template content changes. The callback receives the full TemplateContent object. Changes are debounced internally.

ts
const editor = await init({
  container: '#editor',
  onChange(content) {
    // Save to your backend
    fetch('/api/templates/123', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(content),
    });
  },
});

onDirtyChange ​

Called whenever the editor gains or loses unsaved changes. Use it to drive your own save button, or to guard a client-side route change — the built-in guard covers tab close, but beforeunload never fires on SPA navigation.

ts
const editor = await init({
  container: '#editor',
  onDirtyChange(isDirty) {
    setRouteGuard(isDirty);
  },
});

onError ​

Called when an error occurs within the editor.

ts
const editor = await init({
  container: '#editor',
  onError(error) {
    console.error('Editor error:', error.message);
    reportToSentry(error);
  },
});

Request Events ​

onRequestMedia ​

UI override for the media picker. Called when the user clicks Browse (e.g. in the image block settings) or drags an image file onto an image block/field. Return a MediaResult object, or null if the user cancels. When alt is provided, the editor automatically fills in the image's alt text.

It wins over a media provider when both are set — the host brought a widget, so the built-in modal never opens. The store itself is the media key.

On a drag-and-drop, the dropped file arrives in context.files — upload it and return the hosted URL. See Drag and drop to upload for the full pattern.

ts
import type { MediaResult } from '@templatical/types';

const editor = await init({
  container: '#editor',
  async onRequestMedia(context?): Promise<MediaResult | null> {
    const image = await openMediaPicker();
    if (!image) return null;
    return { url: image.url, alt: image.alt };
  },
});

If you provide neither onRequestMedia nor media, the editor shows a text input where users type or paste image URLs directly.

mergeTags.onRequest ​

Called when the user clicks to insert a merge tag in a title or paragraph block. Return a Promise that resolves to a MergeTag object or null if the user cancels.

ts
import type { MergeTag } from '@templatical/types';

const editor = await init({
  container: '#editor',
  mergeTags: {
    tags: [
      { label: 'First Name', value: '{{first_name}}' },
      { label: 'Email', value: '{{email}}' },
    ],
    async onRequest(): Promise<MergeTag | null> {
      // Show your own picker UI and return the selected tag
      const tag = await showMergeTagPicker();
      return tag; // or null if cancelled
    },
  },
});

If you provide mergeTags.tags without onRequest, the editor uses a built-in dropdown populated with your tags. The onRequest callback lets you replace that dropdown with your own UI.

Template Events ​

A templates provider carries events beyond load / create / save — onSaved, onCreated and onLoaded — fired once the editor has settled: the template adopted, isDirty cleared, isSaving/isLoading false.

ts
const editor = await init({
  container: '#editor',
  templates: {
    load, create, save,
    onSaved(template, { trigger }) {
      if (trigger === 'manual') navigate('/templates');
    },
  },
});

onSaved's second argument names which action triggered the save, so a handler can act on a save the user asked for without also firing on every autosave tick. See TemplatesOptions for the full reference.

Comment Events ​

A comments provider carries events beyond list / create / update / delete / setResolved — onCreated, onUpdated, onDeleted, onResolved and onUnresolved — fired once the editor has applied the change, whether it came from a local write or arrived through subscribe.

ts
const editor = await init({
  container: '#editor',
  user: { id: 'u_7', name: 'Ada Lovelace' },
  comments: {
    ...myCommentsProvider,
    onCreated(comment, { origin }) {
      if (origin === 'remote') incrementUnread();
    },
  },
});

Each handler's second argument carries origin — 'local' for a write this editor made, 'remote' for one that arrived through subscribe. See Events for the full reference, including which of onResolved / onUnresolved fires.

Saved Block Events ​

A savedBlocks provider carries events beyond list / create / update / delete — onCreated, onUpdated and onDeleted — fired once the editor has applied the change to its own list.

ts
const editor = await init({
  container: '#editor',
  savedBlocks: {
    ...mySavedBlocksProvider,
    onDeleted(block) {
      logRemoval(block.id);
    },
  },
});

onDeleted receives the removed SavedBlock itself, not an id — delete resolves to nothing, so the editor passes the entry it captured before removing it. See Events for the full reference.

Version History Events ​

A versionHistory provider carries events beyond list / get / create / restore — onCreated and onRestored — fired once create() or restore() resolves.

ts
const editor = await init({
  container: '#editor',
  versionHistory: {
    ...myVersionHistoryProvider,
    onRestored(template) {
      navigate(`/templates/${template.id}`);
    },
  },
});

onRestored takes the resulting Template that restore() resolves to, not the TemplateVersion that was restored from. See Events for the full reference.

Media Events ​

A media provider carries events beyond list / create / update / delete — onCreated, onUpdated and onDeleted — fired once the matching mutation resolves. There is no editor-level media list: a drop-create fires onCreated with the stored asset and never prepends a listing row.

ts
const editor = await init({
  container: '#editor',
  media: {
    ...myMediaProvider,
    onDeleted(asset) {
      logRemoval(asset.id);
    },
  },
});

onDeleted receives the removed MediaAsset itself, not an id — delete resolves to nothing, so the handler gets the entry captured from the modal's loaded listing before removing it. See Events for the full reference.

Test Email Events ​

A testEmail provider carries one event beyond send — onSent — fired once a send resolves, with the same payload send was given.

ts
const editor = await init({
  container: '#editor',
  testEmail: {
    ...myTestEmailProvider,
    onSent(payload) {
      trackEvent('test_email_sent', { recipient: payload.recipient });
    },
  },
});

Not called for a rejected send — that surfaces through the dialog's own inline error instead. See Events for the full reference.

Patterns ​

Debounced Auto-Save ​

ts
let saveTimeout: ReturnType<typeof setTimeout>;

const editor = await init({
  container: '#editor',
  onChange(content) {
    clearTimeout(saveTimeout);
    saveTimeout = setTimeout(() => {
      saveToBackend(content);
    }, 2000);
  },
});

Dirty State Tracking ​

ts
const editor = await init({
  container: '#editor',
  onDirtyChange(isDirty) {
    updateSaveButton(isDirty);
  },
});

The editor already warns on tab close when a templates provider is configured — opt out with templates: { unsavedChangesGuard: false }. onDirtyChange is what you need for a client-side router, which beforeunload cannot see.