Listening to events
The editor tells your page what happens inside it: a design opened, changed, saved; a block selected; an image uploaded; the theme switched; an option adjusted; an error. You listen, and your product reacts: keep the design's id, show "Saved", preview live, log what went wrong.
Nothing waits for your listener. It cannot slow the editor down, and one that throws does not affect it. Every payload may gain fields within 1.x, so ignore fields you do not know. The Events reference lists every field of every event; this page shows what to do with them.
The nine events
| Event | When | Use it for |
|---|---|---|
ready | The editor is on screen and answers calls | Enabling your own buttons; reading which release opened |
design:loaded | A design opened, new or existing | Knowing the id from the start; noticing an upgrade from an older format |
design:updated | The design changed (a burst of edits is one event) | "Saving…", a live preview, your own copy of the design |
design:saved | The design was saved (Lettrove saves as your user works) | Keeping the design's id with your own record; "Saved" |
selection:changed | The selected block changed, or nothing is selected | Help or controls for the kind of block selected |
image:uploaded | An upload is ready to use | Logging, your own count of images |
theme:changed | Your user used the editor's light / dark switch | Remembering their choice for next time |
config:adjusted | An option you passed was dropped or changed | Finding a mistake while building |
error | Something went wrong that you may want to show or log | Showing a message by code; logging |
In the order they happen in a session:
design:loaded { designId: '01J9ZQ…', designVersion: 3 }
ready { release: '1.0.0', protocol: 1, designVersion: 3 }
selection:changed { blockId: 'hdg_k2x4q7m1', type: 'heading' }
design:updated { design: {…}, revision: 1, change: { kind: 'edit', … } }
design:saved { designId: '01J9ZQ…', revision: 2 }
Step 1: listen from the start
Pick your framework. Each example listens for the two events almost every product needs, the design being saved and an error, and shows where the rest go.
- Next.js
- React
- Vue
- Angular
- SvelteKit
- HTML
Every event is a prop of <LettroveEditor />: onReady, onDesignLoaded, onDesignUpdated,
onDesignSaved, onSelectionChanged, onImageUploaded, onThemeChanged, onConfigAdjusted
and onError. The editor runs in the browser, so this is a client component, as on the
Next.js page:
'use client';
import { LettroveEditor } from '@lettrove/react';
async function getToken() {
const response = await fetch('/lettrove-token', { method: 'POST' });
return (await response.json()).token;
}
export function EmailEditor({ recordId }: { recordId: string }) {
return (
<LettroveEditor
publishableKey={process.env.NEXT_PUBLIC_LETTROVE_PUBLISHABLE_KEY!}
getToken={getToken}
onDesignSaved={({ designId }) => {
// Keep the design's id with your own record (an API route, say)
void fetch(`/api/records/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ lettroveDesignId: designId }),
});
}}
onError={(error) => console.error('Lettrove', error.code, error.message)}
style={{ height: '100vh' }}
/>
);
}
Inline functions are fine: a new function on every render never reopens the editor.
Every event is a prop of <LettroveEditor />: onReady, onDesignLoaded, onDesignUpdated,
onDesignSaved, onSelectionChanged, onImageUploaded, onThemeChanged, onConfigAdjusted
and onError.
import { LettroveEditor } from '@lettrove/react';
async function getToken() {
const response = await fetch('/lettrove-token', { method: 'POST' });
return (await response.json()).token;
}
export function Editor({ recordId }: { recordId: string }) {
return (
<LettroveEditor
publishableKey="lt_pk_test_paste_yours_here"
getToken={getToken}
onDesignSaved={({ designId }) => {
// Keep the design's id with your own record
void fetch(`/api/records/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ lettroveDesignId: designId }),
});
}}
onError={(error) => console.error('Lettrove', error.code, error.message)}
style={{ height: '100vh' }}
/>
);
}
Inline functions are fine: a new function on every render never reopens the editor.
Every event is a component event, in kebab-case: @ready, @design-loaded, @design-updated,
@design-saved, @selection-changed, @image-uploaded, @theme-changed, @config-adjusted
and @error.
<script setup lang="ts">
import {
LettroveEditor, type EditorEvents, type LettroveEmbedError,
} from '@lettrove/vue';
const props = defineProps<{ recordId: string }>();
async function getToken() {
const response = await fetch('/lettrove-token', { method: 'POST' });
return (await response.json()).token;
}
function onSaved({ designId }: EditorEvents['design:saved']) {
// Keep the design's id with your own record
void fetch(`/api/records/${props.recordId}`, {
method: 'PATCH',
body: JSON.stringify({ lettroveDesignId: designId }),
});
}
function onError(error: LettroveEmbedError) {
console.error('Lettrove', error.code, error.message);
}
</script>
<template>
<LettroveEditor
publishable-key="lt_pk_test_paste_yours_here"
:get-token="getToken"
@design-saved="onSaved"
@error="onError"
style="height: 100vh"
/>
</template>
Listeners go in on when you open the editor, so nothing is missed while it loads. Add more at
any time with editor.on(), which returns a function that stops listening.
import {
AfterViewInit, Component, ElementRef, OnDestroy, viewChild,
} from '@angular/core';
import { createEditor, type EditorHandle } from '@lettrove/embed';
async function getToken() {
const response = await fetch('/lettrove-token', { method: 'POST' });
return (await response.json()).token;
}
@Component({
selector: 'app-root',
template: '<div #editor style="height: 100vh"></div>',
})
export class App implements AfterViewInit, OnDestroy {
private readonly container =
viewChild.required<ElementRef<HTMLDivElement>>('editor');
private editor: EditorHandle | null = null;
recordId = 'rec_123'; // your own record
async ngAfterViewInit() {
this.editor = await createEditor({
container: this.container().nativeElement,
publishableKey: 'lt_pk_test_paste_yours_here',
getToken,
on: {
'design:saved': ({ designId }) => {
// Keep the design's id with your own record
void fetch(`/api/records/${this.recordId}`, {
method: 'PATCH',
body: JSON.stringify({ lettroveDesignId: designId }),
});
},
error: ({ code, message }) => console.error('Lettrove', code, message),
},
});
}
ngOnDestroy() {
this.editor?.destroy();
}
}
Listeners go in on when you open the editor, so nothing is missed while it loads. Add more at
any time with editor.on(), which returns a function that stops listening.
<script lang="ts">
import { onMount } from 'svelte';
import { createEditor, type EditorHandle } from '@lettrove/embed';
let { recordId }: { recordId: string } = $props();
let container: HTMLDivElement;
let editor: EditorHandle | null = null;
async function getToken() {
const response = await fetch('/lettrove-token', { method: 'POST' });
return (await response.json()).token;
}
onMount(() => {
createEditor({
container,
publishableKey: 'lt_pk_test_paste_yours_here',
getToken,
on: {
'design:saved': ({ designId }) => {
// Keep the design's id with your own record
void fetch(`/api/records/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ lettroveDesignId: designId }),
});
},
error: ({ code, message }) => console.error('Lettrove', code, message),
},
}).then((opened) => (editor = opened));
return () => editor?.destroy();
});
</script>
<div bind:this={container} style="height: 100vh"></div>
Listeners go in on when you open the editor, so nothing is missed while it loads. Add more at
any time with editor.on(), which returns a function that stops listening.
<div id="editor" style="height: 100vh"></div>
<script src="https://cdn.jsdelivr.net/npm/@lettrove/embed@1/dist/lettrove.iife.js"></script>
<script>
async function getToken() {
const response = await fetch('/lettrove-token', { method: 'POST' });
return (await response.json()).token;
}
const recordId = 'rec_123'; // your own record
Lettrove.createEditor({
container: '#editor',
publishableKey: 'lt_pk_test_paste_yours_here',
getToken,
on: {
'design:saved': ({ designId }) => {
// Keep the design's id with your own record
fetch(`/api/records/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ lettroveDesignId: designId }),
});
},
error: ({ code, message }) => console.error('Lettrove', code, message),
},
}).then((editor) => {
// Later, from anywhere:
const stop = editor.on('selection:changed', ({ type }) => {
console.log(type);
});
// stop(); // when you no longer want it
});
</script>
Step 2: what to do with each event
Each example below is written for createEditor. In React the same listener is the matching prop
(onDesignUpdated); in Vue the matching event (@design-updated).
Keep the design's id (design:saved)
The designId is the whole link between your product and the design: open the editor with it
again, export it, read it from your server. Save it with your own record the first time it arrives;
it never changes afterwards.
on: {
'design:saved': ({ designId, revision }) => {
if (!myRecord.lettroveDesignId) {
saveToMyRecord({ lettroveDesignId: designId });
}
console.log('revision', revision); // goes up by one with each save
},
}
A new design has its id from the start, so design:loaded carries it too; design:saved is the
moment it is safe to rely on.
"Saving… / Saved" (design:updated + design:saved)
Lettrove saves as your user works. Show it, so nobody wonders whether to click a save button that is not there:
let status: 'saved' | 'saving' = 'saved';
on: {
'design:updated': () => { status = 'saving'; render(); },
'design:saved': () => { status = 'saved'; render(); },
}
In React, two props and a piece of state:
const [status, setStatus] = useState<'saved' | 'saving'>('saved');
<LettroveEditor
onDesignUpdated={() => setStatus('saving')}
onDesignSaved={() => setStatus('saved')}
// …
/>
A live preview, or your own copy (design:updated)
design:updated carries the whole design as it is on screen, and what changed. A burst of edits is
one event, so reacting to it never slows typing down. To render it yourself, hand the design to
exportHtml or your server; to keep your own copy, store it as it is: the
design document is yours to hold (what you may rely on).
on: {
'design:updated': ({ design, revision, change }) => {
// change.kind: 'edit' | 'undo' | 'redo' | 'restore' | 'assistant'
// change.blockId: the block changed, when the change was to one block
myDraft = { design, revision };
if (change.kind === 'assistant') showNote('The assistant changed it');
},
}
To keep every design in your own database instead of ours, do not copy it from events: use your own design storage, which handles revisions and conflicts.
Help for the selected block (selection:changed)
on: {
'selection:changed': ({ blockId, type }) => {
// type: 'heading' | 'text' | 'image' | 'button' | …
// null when nothing is selected
showHelpPanel(type ? `help/blocks/${type}` : 'help/getting-started');
},
}
Remember the theme (theme:changed)
When you keep the editor's own light / dark switch, your user's choice arrives here. Remember it
and pass it back as appearance.theme next time, so the editor opens as they left it:
on: {
'theme:changed': ({ theme, applied }) => {
// theme: what they picked, 'light' | 'dark' | 'auto'
// applied: what is showing now, 'light' | 'dark'
savePreference('lettroveTheme', theme);
},
}
// Next time:
createEditor({
appearance: { theme: loadPreference('lettroveTheme') ?? 'auto' },
// …
});
With your own switch you set the theme yourself instead: see Branding.
Know when an image is uploaded (image:uploaded)
on: {
'image:uploaded': ({ url, width, height, id }) => {
console.log('uploaded', url, width, height, id);
},
}
To keep uploads in your own storage, use your own upload handler: this event then reports the address your handler returned.
Find a mistake while building (config:adjusted)
An option you passed was dropped or changed: a typo with a live key, or something the project's
plan does not include (options only narrow). Log it
loudly in development. With a test key an invalid option stops the editor instead, with
options_invalid, so you find it before it ships.
on: {
'config:adjusted': ({ option, reason }) => {
console.warn(`Lettrove ignored ${option}: ${reason}`);
},
}
Show the right message (error)
Every error has a stable code (error codes) and a message in words.
recoverable is true when trying again can help and the editor keeps working (a token that
arrived late, say); when it is absent or false, the editor has stopped.
on: {
error: ({ code, message, recoverable }) => {
reportToMyLogs({ code, message });
if (recoverable) return;
if (code === 'token_expired' || code === 'token_invalid') {
showMessage('Please sign in again.');
} else if (code === 'quota_exceeded') {
showMessage('This workspace is out of credits.');
} else {
showMessage('The editor could not open. We have been told.');
}
},
}
Decide on the code, never on the message: messages may be reworded, codes never change meaning.
Wait for the editor (ready)
createEditor resolves at the same moment ready fires, so you rarely need the event itself.
It carries the editor release that opened, useful in your logs:
const editor = await createEditor({ /* … */ });
console.log('Lettrove editor', editor.release); // e.g. '1.0.0'
myToolbar.enable();
In React, onReady={(editor) => …} gives you the handle; in Vue, @ready.
Notice an upgraded or newer design (design:loaded)
on: {
'design:loaded': ({ designId, upgradedFrom, hasNewerBlocks }) => {
// upgradedFrom: it was stored in an older format and upgraded as it opened
// hasNewerBlocks: it holds blocks from a newer editor, kept as they are
if (hasNewerBlocks) {
showNote('Some blocks come from a newer editor and are kept as they are.');
}
},
}
Listening later, and stopping
editor.on(name, listener) adds a listener at any time and returns a function that removes it:
const stop = editor.on('selection:changed', ({ type }) => showHelpFor(type));
// …
stop();
Listeners in on at open live as long as the editor. Destroying the editor (or unmounting the
React or Vue component) removes every listener.
For your AI assistant
- Nine events:
ready,design:loaded,design:updated,design:saved,selection:changed,image:uploaded,theme:changed,config:adjusted,error. Listen withon: {…}at open oreditor.on()later (returns an unsubscribe function). React:onXprops (onDesignSaved); Vue: kebab-case events (@design-saved);onReady/@readyreceive the editor handle,onError/@erroraLettroveEmbedError. - Keep
designIdfromdesign:savedwith your own record; it is the link to the design. design:updatedis debounced and carries the whole design; it is for previews and status, not for storing designs yourself (usestorage.designs: 'host'for that).- Decide on
error.code, never on the message.