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:

PhaseStatus barSave buttonSaved state (dirty)
Pending"Saving…"disabled; Ctrl/Cmd+S ignoredunchanged
Resolved"Saved"enabledthe value that was sent. Edits made while saving stay unsaved.
Rejected (throw)"Save failed: <your message>"enabledunchanged; 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.dirty tells whether the template differs from the saved state. The dirty-change event ({ 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-unsaved attribute 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 calledThe loaded document. A stored draft is not restored and is replaced by the next autosave.
Only value / sample-data were setA stored draft wins over them (the draft is newer work on the same document).
NothingThe 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 canDeactivate guard that checks designer.dirty.
  • $event is typed Event in templates; $any(...) or a cast reads detail.

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 of v-model: v-model listens to input, and the editor's inner text fields emit native input events while the user types, before the template has changed.

Next#