/** * Label Designer — design model, element factories, geometry + undo/redo. * * The single source of truth for a label is a `LabelTemplate`: a physical size * (mm) plus an ordered list of elements (array order == z-order, last on top). * The SAME model is consumed by the on-screen SVG renderer * (`IntegrationsLabeldesignerStage`) and by the PDF compiler * (`usePdfCompiler`), so what the user designs prints true-to-size. * * All geometry is in millimetres, origin top-left, `rotation` in degrees * clockwise about the element's box centre. SSR-safe — no browser APIs here. * * `useLabelDesign()` returns a fresh, page-local controller (NOT useState) — * one editor instance per page mount. */ export type LdElementType = | 'text' | 'barcode' | 'qr' | 'datamatrix' | 'image' | 'rect' | 'ellipse' | 'line' export interface LdBaseElement { id: string type: LdElementType name?: string x: number; y: number; w: number; h: number rotation: number locked?: boolean visible?: boolean } export interface LdTextElement extends LdBaseElement { type: 'text' text: string fontFamily: 'helvetica' | 'times' | 'courier' fontSizePt: number fontStyle: 'normal' | 'bold' | 'italic' | 'bolditalic' color: string align: 'left' | 'center' | 'right' vAlign: 'top' | 'middle' | 'bottom' lineHeight: number letterSpacingPt?: number wrap: boolean } export interface LdBarcodeElement extends LdBaseElement { type: 'barcode' symbology: 'code128' | 'code39' | 'ean13' | 'ean8' | 'upca' | 'itf14' | 'gs1-128' value: string showText: boolean barColor: string bgColor: string } export interface LdQrElement extends LdBaseElement { type: 'qr' value: string eccLevel: 'L' | 'M' | 'Q' | 'H' barColor: string bgColor: string } export interface LdDataMatrixElement extends LdBaseElement { type: 'datamatrix' value: string barColor: string bgColor: string } export interface LdImageElement extends LdBaseElement { type: 'image' dataUrl: string fit: 'fill' | 'contain' | 'cover' opacity?: number } export interface LdRectElement extends LdBaseElement { type: 'rect' fill: string stroke: string strokeWidthMm: number radiusMm: number } export interface LdEllipseElement extends LdBaseElement { type: 'ellipse' fill: string stroke: string strokeWidthMm: number } export interface LdLineElement extends LdBaseElement { type: 'line' stroke: string strokeWidthMm: number dash?: number[] } export type LdElement = | LdTextElement | LdBarcodeElement | LdQrElement | LdDataMatrixElement | LdImageElement | LdRectElement | LdEllipseElement | LdLineElement export interface LabelTemplate { schemaVersion: 1 id?: number name: string description?: string size: { wMm: number; hMm: number; preset?: string } background: string marginMm: number defaultPrinter?: string elements: LdElement[] } export interface LabelSizePreset { name: string; wMm: number; hMm: number } export const LABEL_SIZE_PRESETS: LabelSizePreset[] = [ { name: '89 × 36 mm — Address', wMm: 89, hMm: 36 }, { name: '102 × 152 mm — Shipping 4×6', wMm: 102, hMm: 152 }, { name: '100 × 150 mm — Shipping', wMm: 100, hMm: 150 }, { name: '62 × 29 mm', wMm: 62, hMm: 29 }, { name: '57 × 32 mm', wMm: 57, hMm: 32 }, { name: '54 × 29 mm', wMm: 54, hMm: 29 }, { name: '75 × 60 mm', wMm: 75, hMm: 60 }, { name: '52 × 74 mm — A8', wMm: 52, hMm: 74 } ] const uid = (): string => { try { if (typeof globalThis !== 'undefined' && (globalThis as any).crypto?.randomUUID) { return (globalThis as any).crypto.randomUUID() } } catch { /* noop */ } return 'el_' + Math.random().toString(36).slice(2, 10) } export const round2 = (n: number): number => Math.round((Number(n) || 0) * 100) / 100 export const clamp = (n: number, min: number, max: number): number => Math.min(Math.max(n, min), max) export function createTemplate (partial: Partial = {}): LabelTemplate { return { schemaVersion: 1, name: partial.name ?? 'Untitled label', description: partial.description ?? '', size: partial.size ?? { wMm: 89, hMm: 36, preset: '89 × 36 mm — Address' }, background: partial.background ?? '#FFFFFF', marginMm: partial.marginMm ?? 2, defaultPrinter: partial.defaultPrinter, id: partial.id, elements: Array.isArray(partial.elements) ? partial.elements : [] } } /** Default element of `type`, positioned with a small cascade so adds don't stack exactly. */ export function createElement (type: LdElementType, index = 0): LdElement { const off = (index % 6) * 3 const base = { id: uid(), type, rotation: 0, visible: true, locked: false, x: round2(4 + off), y: round2(4 + off) } switch (type) { case 'text': return { ...base, name: 'Text', w: 45, h: 10, text: 'Text', fontFamily: 'helvetica', fontSizePt: 11, fontStyle: 'normal', color: '#000000', align: 'left', vAlign: 'top', lineHeight: 1.15, letterSpacingPt: 0, wrap: true } as LdTextElement case 'barcode': return { ...base, name: 'Barcode', w: 50, h: 18, symbology: 'code128', value: '12345678', showText: true, barColor: '#000000', bgColor: '#FFFFFF' } as LdBarcodeElement case 'qr': return { ...base, name: 'QR Code', w: 25, h: 25, value: 'https://app.logship.de', eccLevel: 'M', barColor: '#000000', bgColor: '#FFFFFF' } as LdQrElement case 'datamatrix': return { ...base, name: 'DataMatrix', w: 20, h: 20, value: '12345678', barColor: '#000000', bgColor: '#FFFFFF' } as LdDataMatrixElement case 'image': return { ...base, name: 'Image', w: 30, h: 30, dataUrl: '', fit: 'contain', opacity: 1 } as LdImageElement case 'rect': return { ...base, name: 'Rectangle', w: 30, h: 20, fill: 'transparent', stroke: '#000000', strokeWidthMm: 0.3, radiusMm: 0 } as LdRectElement case 'ellipse': return { ...base, name: 'Ellipse', w: 30, h: 20, fill: 'transparent', stroke: '#000000', strokeWidthMm: 0.3 } as LdEllipseElement case 'line': return { ...base, name: 'Line', w: 40, h: 0, stroke: '#000000', strokeWidthMm: 0.3 } as LdLineElement } } export function useLabelDesign () { const template = ref(createTemplate()) const selectedId = ref(null) // --- undo / redo (full-state snapshots, capped) --- const past = ref([]) const future = ref([]) const HISTORY_CAP = 60 const snapshot = () => JSON.stringify(template.value) /** Call right BEFORE a mutation so undo returns to the pre-change state. */ const record = () => { past.value.push(snapshot()) if (past.value.length > HISTORY_CAP) past.value.shift() future.value = [] } const canUndo = computed(() => past.value.length > 0) const canRedo = computed(() => future.value.length > 0) const undo = () => { if (!past.value.length) return future.value.push(snapshot()) template.value = JSON.parse(past.value.pop() as string) if (selectedId.value && !template.value.elements.some(e => e.id === selectedId.value)) selectedId.value = null } const redo = () => { if (!future.value.length) return past.value.push(snapshot()) template.value = JSON.parse(future.value.pop() as string) if (selectedId.value && !template.value.elements.some(e => e.id === selectedId.value)) selectedId.value = null } // --- selection --- const selected = computed( () => template.value.elements.find(e => e.id === selectedId.value) ?? null ) const select = (id: string | null) => { selectedId.value = id } const indexOf = (id: string) => template.value.elements.findIndex(e => e.id === id) // --- element CRUD (auto-record history) --- const addElement = (type: LdElementType, patch: Partial = {}): LdElement => { record() const el = { ...createElement(type, template.value.elements.length), ...patch } as LdElement template.value.elements.push(el) selectedId.value = el.id return el } const removeElement = (id: string) => { const i = indexOf(id) if (i < 0) return record() template.value.elements.splice(i, 1) if (selectedId.value === id) selectedId.value = null } const duplicateElement = (id: string) => { const el = template.value.elements.find(e => e.id === id) if (!el) return record() const copy = { ...JSON.parse(JSON.stringify(el)), id: uid(), x: round2(el.x + 3), y: round2(el.y + 3), name: (el.name || el.type) + ' copy' } as LdElement template.value.elements.push(copy) selectedId.value = copy.id } /** Patch the selected/identified element. Pass `commit:false` to skip a history entry (live drag). */ const updateElement = (id: string, patch: Partial, commit = true) => { const el = template.value.elements.find(e => e.id === id) if (!el) return if (commit) record() Object.assign(el, patch) } // --- z-order --- const move = (id: string, to: number) => { const i = indexOf(id) if (i < 0) return const clampedTo = clamp(to, 0, template.value.elements.length - 1) if (i === clampedTo) return record() const [el] = template.value.elements.splice(i, 1) template.value.elements.splice(clampedTo, 0, el) } const bringToFront = (id: string) => move(id, template.value.elements.length - 1) const sendToBack = (id: string) => move(id, 0) const bringForward = (id: string) => move(id, indexOf(id) + 1) const sendBackward = (id: string) => move(id, indexOf(id) - 1) // --- label size --- const setSize = (wMm: number, hMm: number, preset?: string) => { record() template.value.size = { wMm: round2(clamp(wMm, 5, 1000)), hMm: round2(clamp(hMm, 5, 1000)), preset } } const setBackground = (color: string) => { record(); template.value.background = color } // --- load / reset --- const loadTemplate = (tpl: LabelTemplate) => { template.value = createTemplate(tpl) selectedId.value = null past.value = [] future.value = [] } const reset = () => loadTemplate(createTemplate()) return { template, selectedId, selected, canUndo, canRedo, LABEL_SIZE_PRESETS, record, undo, redo, select, addElement, removeElement, duplicateElement, updateElement, bringToFront, sendToBack, bringForward, sendBackward, setSize, setBackground, loadTemplate, reset } }