Embed the editor
Put <zpl-editor> on your template page, load a template from your database into it, and store what the user saves.
This page covers that load/save contract and complete recipes for plain HTML, Angular, React and Vue. Every attribute,
event and method is listed in the Reference.
Examples use https://labelviewer.example.internal for your install's address. The /api/label-templates/:id
endpoints stand for your own backend (a table for them is on Store templates).
1. Load the SDK#
<script type="module" src="https://labelviewer.example.internal/sdk/1.0.1/labelviewer-elements.min.js"
integrity="sha384-<hash from the SDK page>" crossorigin="anonymous"></script>
<zpl-editor id="designer" warn-unsaved></zpl-editor>
Copy the exact URL and its integrity hash from the SDK page. Loaded from there, the editor fetches its
fonts from the same place and its Test with API button calls the same install: nothing else to configure. The SDK
page also covers dynamic import(), TypeScript types, npm and CSP.
The editor is height: 640px by default and needs about 1000 px of width; give it the space of your page.
2. Open a template: load()#
const designer = document.getElementById('designer');
const row = await (await fetch('/api/label-templates/42')).json(); // your backend
designer.load({
zpl: row.zpl,
sampleData: row.sample_data ?? undefined, // object or array of records, for the preview
dpmm: row.dpmm, // 6, 8, 12 or 24
width: row.width_in, // inches
height: row.height_in,
fileName: `${row.name}.zpl`,
});
load() replaces the document in one step: the undo history is cleared, the loaded template becomes the saved state
(dirty is false), and no change event fires. It throws a TypeError for a non-string zpl, an unknown density or
a size outside 0.1-15 in. Before the element has started it is remembered and applied on connect.
3. Save it: saveHandler#
designer.saveHandler = async ({ value, sampleData, settings }) => {
const res = await fetch('/api/label-templates/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zpl: value,
sample_data: sampleData ?? null,
dpmm: settings.dpmm,
width_in: settings.widthIn,
height_in: settings.heightIn,
base_version: row.version,
}),
});
if (!res.ok) {
throw new Error(res.status === 409 ? 'someone else changed this template' : `HTTP ${res.status}`);
}
row = await res.json();
};
When the user presses Save or Ctrl/Cmd+S, the editor fires the save event, then calls your handler:
| Phase | Status bar | Save button | Saved state (dirty) |
|---|---|---|---|
| Pending | "Saving…" | disabled; Ctrl/Cmd+S ignored | unchanged |
| Resolved | "Saved" | enabled | the value that was sent. Edits made while saving stay unsaved. |
| Rejected (throw) | "Save failed: <your message>" | enabled | unchanged; an error event with source: 'save' |
If your backend normalizes the template, return { value: storedZpl }: the editor takes that as the saved state and,
if the user did not edit in the meantime, shows it (one undo step, no change event).
Without a saveHandler: the save event and markSaved()#
If your app stores templates through its own form or state management, leave saveHandler unset and listen to save:
designer.addEventListener('save', async (e) => {
const { value, sampleData, settings, fileName } = e.detail; // sampleData is a copy, undefined when there is none
await myStore.saveTemplate(42, { zpl: value, sampleData, ...settings });
designer.markSaved(value); // the template stays "unsaved" until you call this
});
4. Unsaved changes#
designer.dirtytells whether the template differs from the saved state. Thedirty-changeevent ({ dirty }) fires whenever that flips: the first edit after a save or load, a successful save,load(),markSaved(), undo back to the saved text. Use it for an "Unsaved changes" badge or to guard your router.- The
warn-unsavedattribute makes the browser ask before the page is closed or reloaded while there are unsaved changes. - New, Open and New from sample inside the editor replace the document but never touch your database: the result is unsaved until the user saves.
Drafts in the browser (autosave-key)#
autosave-key="label-template-42" keeps a draft of the current document in the browser's localStorage, so work
survives a reload. Use one key per template. Which document wins when the element starts:
| Before the element starts (connects) | Result |
|---|---|
load(...) was called | The loaded document. A stored draft is not restored and is replaced by the next autosave. |
Only value / sample-data were set | A stored draft wins over them (the draft is newer work on the same document). |
| Nothing | The stored draft, else a blank label. |
A load() after the element has started replaces whatever is shown, including a restored draft. Leave drafts off when
templates or sample data are sensitive on shared machines; without autosave-key the editor writes nothing.
Recipes#
Each recipe loads the SDK from your install, opens a template with load(), saves it with saveHandler, and shows the
unsaved state. They are complete: copy one and replace the URLs.
Plain HTML#
<!-- index.html -->
<script type="module" src="https://labelviewer.example.internal/sdk/1.0.1/labelviewer-elements.min.js"
integrity="sha384-<hash from the SDK page>" crossorigin="anonymous"></script>
<script type="module" src="/js/label-designer.js"></script>
<span id="state"></span>
<zpl-editor id="designer" warn-unsaved></zpl-editor>
// /js/label-designer.js
const designer = document.getElementById('designer');
const id = new URLSearchParams(location.search).get('id');
let row = await (await fetch(`/api/label-templates/${id}`)).json();
await customElements.whenDefined('zpl-editor');
designer.load({ zpl: row.zpl, sampleData: row.sample_data ?? undefined, dpmm: row.dpmm, width: row.width_in, height: row.height_in });
designer.saveHandler = async ({ value, sampleData, settings }) => {
const res = await fetch(`/api/label-templates/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zpl: value, sample_data: sampleData ?? null, dpmm: settings.dpmm, width_in: settings.widthIn, height_in: settings.heightIn, base_version: row.version }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
row = await res.json();
};
designer.addEventListener('dirty-change', (e) => {
document.getElementById('state').textContent = e.detail.dirty ? 'Unsaved changes' : 'Saved';
});
Angular#
A service loads the SDK once (dynamic import of the install's URL), another talks to your backend, and the component
binds the element. CUSTOM_ELEMENTS_SCHEMA lets Angular accept the tag.
// labelviewer.service.ts
import { Injectable } from '@angular/core';
export const LABELVIEWER_SDK = 'https://labelviewer.example.internal/sdk/1.0.1/labelviewer-elements.min.js';
@Injectable({ providedIn: 'root' })
export class LabelviewerService {
private loading?: Promise<unknown>;
/** Defines <zpl-editor> and <zpl-viewer>; safe to call many times. */
load(): Promise<unknown> {
// webpackIgnore: the Angular CLI must not try to bundle a URL.
this.loading ??= import(/* webpackIgnore: true */ /* @vite-ignore */ LABELVIEWER_SDK);
return this.loading;
}
}
// label-templates.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export interface LabelTemplate {
id: number;
name: string;
zpl: string;
sample_data: Record<string, unknown> | Record<string, unknown>[] | null;
dpmm: 6 | 8 | 12 | 24;
width_in: number;
height_in: number;
version: number;
}
@Injectable({ providedIn: 'root' })
export class LabelTemplatesService {
private http = inject(HttpClient);
get(id: number) {
return firstValueFrom(this.http.get<LabelTemplate>(`/api/label-templates/${id}`));
}
save(t: LabelTemplate) {
return firstValueFrom(this.http.put<LabelTemplate>(`/api/label-templates/${t.id}`, { ...t, base_version: t.version }));
}
}
// label-designer.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, inject, input, signal, viewChild, effect } from '@angular/core';
import type { ZplEditorElement, ZplSaveDetail } from '@labelviewer/elements'; // types: see the SDK page
import { LabelviewerService } from './labelviewer.service';
import { LabelTemplate, LabelTemplatesService } from './label-templates.service';
@Component({
selector: 'app-label-designer',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<p>{{ dirty() ? 'Unsaved changes' : 'Saved' }}</p>
<zpl-editor #designer warn-unsaved (dirty-change)="dirty.set($any($event).detail.dirty)"></zpl-editor>
`,
styles: [`zpl-editor { display: block; height: 80vh; }`],
})
export class LabelDesignerComponent {
readonly templateId = input.required<number>();
readonly dirty = signal(false);
private readonly designer = viewChild.required<ElementRef<ZplEditorElement>>('designer');
private readonly sdk = inject(LabelviewerService);
private readonly templates = inject(LabelTemplatesService);
private row?: LabelTemplate;
constructor() {
effect(() => void this.open(this.templateId()));
}
private async open(id: number) {
await this.sdk.load();
const row = await this.templates.get(id);
this.row = row;
const el = this.designer().nativeElement;
el.load({ zpl: row.zpl, sampleData: row.sample_data ?? undefined, dpmm: row.dpmm, width: row.width_in, height: row.height_in, fileName: `${row.name}.zpl` });
el.saveHandler = (d) => this.save(d);
}
private async save({ value, sampleData, settings }: ZplSaveDetail) {
// A rejected promise shows "Save failed: <message>" in the editor and keeps the changes.
this.row = await this.templates.save({
...this.row!, zpl: value, sample_data: (sampleData as LabelTemplate['sample_data']) ?? null,
dpmm: settings.dpmm, width_in: settings.widthIn, height_in: settings.heightIn,
});
}
}
- To block navigation with unsaved changes, add a
canDeactivateguard that checksdesigner.dirty. $eventis typedEventin templates;$any(...)or a cast readsdetail.
React#
A hook loads the SDK once and wires load() and saveHandler to your API. Works with React 18 and 19 (it uses a ref,
not JSX props).
// useLabelDesigner.ts
import { useEffect, useRef, useState } from 'react';
import type { ZplEditorElement } from '@labelviewer/elements'; // types: see the SDK page
const LABELVIEWER_SDK = 'https://labelviewer.example.internal/sdk/1.0.1/labelviewer-elements.min.js';
let sdk: Promise<unknown> | undefined;
const loadSdk = () => (sdk ??= import(/* @vite-ignore */ /* webpackIgnore: true */ LABELVIEWER_SDK));
export function useLabelDesigner(templateId: string) {
const ref = useRef<ZplEditorElement>(null);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let row: any;
(async () => {
await loadSdk();
row = await (await fetch(`/api/label-templates/${templateId}`)).json();
const el = ref.current;
if (cancelled || !el) {
return;
}
el.load({ zpl: row.zpl, sampleData: row.sample_data ?? undefined, dpmm: row.dpmm, width: row.width_in, height: row.height_in });
el.saveHandler = async ({ value, sampleData, settings }) => {
const res = await fetch(`/api/label-templates/${templateId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zpl: value, sample_data: sampleData ?? null, dpmm: settings.dpmm, width_in: settings.widthIn, height_in: settings.heightIn, base_version: row.version }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
row = await res.json();
};
})().catch((e) => setError(String(e.message ?? e)));
const el = ref.current;
const onDirty = (e: Event) => setDirty((e as CustomEvent<{ dirty: boolean }>).detail.dirty);
el?.addEventListener('dirty-change', onDirty);
return () => {
cancelled = true;
el?.removeEventListener('dirty-change', onDirty);
};
}, [templateId]);
return { ref, dirty, error };
}
// LabelDesigner.tsx
import { useLabelDesigner } from './useLabelDesigner';
export function LabelDesigner({ templateId }: { templateId: string }) {
const { ref, dirty, error } = useLabelDesigner(templateId);
return (
<>
<p>{error ?? (dirty ? 'Unsaved changes' : 'Saved')}</p>
<zpl-editor ref={ref} warn-unsaved="" style={{ display: 'block', height: '80vh' }} />
</>
);
}
In Next.js, render this component on the client only (dynamic(() => import('./LabelDesigner'), { ssr: false })). With
React 19 you can also pass properties and lower-case event props in JSX (onsave, ondirty-change), see
SDK › React.
Vue#
// vite.config.ts: let Vue treat the tags as custom elements
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('zpl-') } } })
<!-- LabelDesigner.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { ZplEditorElement, ZplSaveDetail } from '@labelviewer/elements';
const props = defineProps<{ templateId: string }>();
const LABELVIEWER_SDK = 'https://labelviewer.example.internal/sdk/1.0.1/labelviewer-elements.min.js';
const designer = ref<ZplEditorElement>();
const dirty = ref(false);
let row: any;
async function save({ value, sampleData, settings }: ZplSaveDetail) {
const res = await fetch(`/api/label-templates/${props.templateId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zpl: value, sample_data: sampleData ?? null, dpmm: settings.dpmm, width_in: settings.widthIn, height_in: settings.heightIn, base_version: row.version }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
row = await res.json();
}
onMounted(async () => {
await import(/* @vite-ignore */ LABELVIEWER_SDK);
row = await (await fetch(`/api/label-templates/${props.templateId}`)).json();
designer.value!.load({ zpl: row.zpl, sampleData: row.sample_data ?? undefined, dpmm: row.dpmm, width: row.width_in, height: row.height_in });
designer.value!.saveHandler = save;
});
</script>
<template>
<p>{{ dirty ? 'Unsaved changes' : 'Saved' }}</p>
<zpl-editor ref="designer" warn-unsaved @dirty-change="dirty = $event.detail.dirty" style="display: block; height: 80vh" />
</template>
- Bind object and function properties in code (as above) or in camelCase (
:saveHandler="save"), so Vue sets them as properties, not attributes. - Use
load()(or:value+@change) instead ofv-model:v-modellistens toinput, and the editor's inner text fields emit nativeinputevents while the user types, before the template has changed.
Next#
- Watch all of this happen in the Playground, then copy its code panel.
- Decide what your users may change with
featuresandreadonly(Reference). - Print the saved templates: Print from your backend.