vcard4reseller/frontend/src/views/CardEditorView.vue
Thomas Peterson 904a4184fc Karten-Editor: Komfort — Hintergrund-Vorschau, Resize, Undo
- Hintergrund-PDF wird per pdf.js echt im Canvas gerendert (WYSIWYG);
  neuer Endpunkt GET .../card-template/background liefert das PDF
- Resize-Anfasser am ausgewählten Element (Breite/Höhe)
- Undo (↶ / Strg+Z) mit Snapshot-History; Snapshot erst bei echter Änderung

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:39:50 +02:00

489 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { list } from '@/api/resources'
import client from '@/api/client'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
interface El { type: string; [k: string]: any }
interface Template {
id: string | null; isDefault: boolean; name: string
widthMm: number; heightMm: number; bleedMm: number; safeMm: number
front: El[]; back: El[]
hasBackground?: boolean; fonts?: string[]
}
interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] }
interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null }
const SCALE = 6 // px pro mm
const companies = ref<Company[]>([])
const selectedCompanyId = ref('')
const tpl = ref<Template | null>(null)
const side = ref<'front' | 'back'>('front')
const selectedIndex = ref<number | null>(null)
const sample = ref<Employee | null>(null)
const employees = ref<Employee[]>([])
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const selectedCompany = computed(() => companies.value.find((c) => c['@id'] === selectedCompanyId.value))
const branding = computed(() => {
const b = selectedCompany.value?.brandingConfig
return (b && !Array.isArray(b) ? b : {}) as Record<string, string>
})
const els = computed<El[]>(() => (tpl.value ? tpl.value[side.value] : []))
const selected = computed(() => (selectedIndex.value !== null ? els.value[selectedIndex.value] : null))
// Hintergrund-PDF als Bild (pro Seite) für die Canvas-Vorschau
const bgImages = ref<{ front: string | null; back: string | null }>({ front: null, back: null })
const currentBg = computed(() => bgImages.value[side.value])
// Undo-History (Snapshots von front/back)
const history = ref<string[]>([])
function snapshot() {
if (!tpl.value) return
history.value.push(JSON.stringify({ front: tpl.value.front, back: tpl.value.back }))
if (history.value.length > 50) history.value.shift()
}
function undo() {
const snap = history.value.pop()
if (!snap || !tpl.value) return
const { front, back } = JSON.parse(snap)
tpl.value.front = front
tpl.value.back = back
selectedIndex.value = null
}
const BINDINGS = [
['fullName', 'Name'], ['firstName', 'Vorname'], ['lastName', 'Nachname'],
['position', 'Position'], ['department', 'Abteilung'], ['email', 'E-Mail'],
['phone', 'Telefon'], ['mobile', 'Mobil'], ['company.name', 'Firma'],
['profileUrl', 'Profil-URL'], ['shortUrl', 'Kurz-URL'],
]
function colorCss(c: any): string {
if (!c) return '#343434'
if (c.hex) return c.hex
switch (c.ref) {
case 'primary': return branding.value.primaryColor || '#f58220'
case 'dark': return branding.value.primaryDark || '#222222'
case 'white': return '#ffffff'
default: return '#343434'
}
}
function bindingValue(b: string): string {
const e = sample.value
if (!e) return `{${b}}`
const map: Record<string, string> = {
fullName: `${e.firstName} ${e.lastName}`.trim(), firstName: e.firstName, lastName: e.lastName,
position: e.position || '', department: e.department || '', email: e.email || '',
phone: e.phone || '', mobile: e.mobile || '', 'company.name': selectedCompany.value?.name || '',
profileUrl: 'profil-url', shortUrl: 'kurz-url',
}
// Leere Werte als Platzhalter zeigen, damit das Element editierbar sichtbar bleibt
return map[b] || `{${b}}`
}
function elText(el: El): string {
const v = el.type === 'field' ? bindingValue(el.binding) : (el.text ?? '')
return (el.prefix ?? '') + v
}
function elStyle(el: El): any {
const base: Record<string, string> = {
left: el.x * SCALE + 'px', top: el.y * SCALE + 'px',
}
if (el.type === 'rect') {
return { ...base, width: el.w * SCALE + 'px', height: el.h * SCALE + 'px', background: colorCss(el.fill) }
}
if (el.type === 'line') {
return { ...base, width: el.w * SCALE + 'px', height: Math.max(1, (el.lineWidth || 0.3) * SCALE) + 'px', background: colorCss(el.color) }
}
if (el.type === 'qr') {
return { ...base, width: el.w * SCALE + 'px', height: el.h * SCALE + 'px' }
}
if (el.type === 'image') {
return { ...base, width: el.w * SCALE + 'px', height: el.h * SCALE + 'px' }
}
// field / text
return {
...base, width: (el.w || 40) * SCALE + 'px',
fontSize: (el.fontSize || 9) * 0.3528 * SCALE + 'px',
fontWeight: el.bold ? '700' : '400',
textAlign: el.align === 'C' ? 'center' : el.align === 'R' ? 'right' : 'left',
color: colorCss(el.color), lineHeight: '1.1', whiteSpace: 'pre-wrap',
}
}
// --- Laden ---
async function loadTemplate() {
if (!selectedCompanyId.value) return
const cid = selectedCompany.value!.id
const { data } = await client.get<Template>(`/companies/${cid}/card-template`)
tpl.value = data
selectedIndex.value = null
history.value = []
sample.value = employees.value.find((e) => e.company === selectedCompanyId.value) ?? null
await renderBackground()
}
// Rendert das Hintergrund-PDF (Seite 1 = Vorder-, 2 = Rückseite) als Bild für den Canvas
async function renderBackground() {
bgImages.value = { front: null, back: null }
if (!tpl.value?.hasBackground) return
try {
const res = await client.get(`/companies/${selectedCompany.value!.id}/card-template/background`, { responseType: 'arraybuffer' })
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise
const pxW = (tpl.value.widthMm + 2 * tpl.value.bleedMm) * SCALE
for (let p = 1; p <= Math.min(2, doc.numPages); p++) {
const page = await doc.getPage(p)
const scale = pxW / page.getViewport({ scale: 1 }).width
const vp = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = vp.width
canvas.height = vp.height
await page.render({ canvas, canvasContext: canvas.getContext('2d')!, viewport: vp }).promise
const url = canvas.toDataURL('image/png')
if (p === 1) bgImages.value.front = url
else bgImages.value.back = url
}
} catch {
// Vorschau optional bei Fehler bleibt der Hinweis-Hintergrund
}
}
async function load() {
loading.value = true
;[companies.value, employees.value] = await Promise.all([
list<Company>('companies').then((r) => r.member),
list<Employee>('employees').then((r) => r.member).catch(() => []),
])
if (companies.value[0]) selectedCompanyId.value = companies.value[0]['@id']
await loadTemplate()
loading.value = false
}
// --- Drag ---
function startDrag(e: MouseEvent, idx: number) {
selectedIndex.value = idx
const el = els.value[idx]
const sx = e.clientX, sy = e.clientY, ox = el.x, oy = el.y
const w = tpl.value!.widthMm, h = tpl.value!.heightMm, bl = tpl.value!.bleedMm
let snapped = false
const move = (ev: MouseEvent) => {
if (!snapped) { snapshot(); snapped = true }
el.x = Math.min(w, Math.max(-bl, +(ox + (ev.clientX - sx) / SCALE).toFixed(1)))
el.y = Math.min(h, Math.max(-bl, +(oy + (ev.clientY - sy) / SCALE).toFixed(1)))
}
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up) }
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
e.preventDefault()
}
function startResize(e: MouseEvent, idx: number) {
selectedIndex.value = idx
const el = els.value[idx]
const sx = e.clientX, sy = e.clientY
const ow = el.w ?? 40, oh = el.h ?? 0
const hasH = ['rect', 'qr', 'image'].includes(el.type)
let snapped = false
const move = (ev: MouseEvent) => {
if (!snapped) { snapshot(); snapped = true }
el.w = Math.max(2, +(ow + (ev.clientX - sx) / SCALE).toFixed(1))
if (hasH) el.h = Math.max(2, +(oh + (ev.clientY - sy) / SCALE).toFixed(1))
}
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up) }
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
e.preventDefault()
e.stopPropagation()
}
// --- Elemente hinzufügen / löschen ---
function add(type: string) {
const defs: Record<string, El> = {
field: { type: 'field', binding: 'fullName', x: 8, y: 8, w: 50, fontSize: 11, bold: false, align: 'L', color: { ref: 'text' } },
text: { type: 'text', text: 'Text', x: 8, y: 8, w: 50, fontSize: 9, bold: false, align: 'L', color: { ref: 'text' } },
qr: { type: 'qr', x: 8, y: 8, w: 18, h: 18 },
image: { type: 'image', binding: 'logo', x: 8, y: 8, w: 30, h: 10, align: 'L' },
rect: { type: 'rect', x: 8, y: 8, w: 30, h: 15, fill: { ref: 'primary' } },
line: { type: 'line', x: 8, y: 8, w: 25, h: 0, lineWidth: 0.5, color: { ref: 'primary' } },
}
snapshot()
els.value.push({ ...defs[type] })
selectedIndex.value = els.value.length - 1
}
function removeSelected() {
if (selectedIndex.value === null) return
snapshot()
els.value.splice(selectedIndex.value, 1)
selectedIndex.value = null
}
// --- Farben im Panel ---
const COLOR_KEY = (el: El) => (el.type === 'rect' ? 'fill' : 'color')
function colorMode(el: El): string {
const c = el[COLOR_KEY(el)]
if (c?.hex) return 'custom'
return c?.ref ?? 'text'
}
function setColorMode(el: El, val: string) {
el[COLOR_KEY(el)] = val === 'custom' ? { hex: '#000000' } : { ref: val }
}
function setColorHex(el: El, hex: string) { el[COLOR_KEY(el)] = { hex } }
// --- Speichern / Vorschau ---
async function save() {
if (!tpl.value || !selectedCompany.value) return
saving.value = true; saved.value = false
try {
const { data } = await client.put<Template>(`/companies/${selectedCompany.value.id}/card-template`, tpl.value)
tpl.value = data
saved.value = true
} finally { saving.value = false }
}
// --- Druck-Assets: Hintergrund-PDF & Schriften ---
const uploading = ref(false)
function cidPath() { return selectedCompany.value?.id }
async function uploadBackground(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !tpl.value) return
uploading.value = true
try {
const fd = new FormData(); fd.append('file', file)
await client.post(`/companies/${cidPath()}/card-template/background`, fd)
tpl.value.hasBackground = true
await renderBackground()
} catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function removeBackground() {
if (!tpl.value || !confirm('Hintergrund-PDF entfernen?')) return
await client.delete(`/companies/${cidPath()}/card-template/background`)
tpl.value.hasBackground = false
bgImages.value = { front: null, back: null }
}
async function uploadFont(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !tpl.value) return
const family = (prompt('Name der Schriftart (z. B. „Brand")', file.name.replace(/\.(ttf|otf)$/i, '')) || '').trim()
if (!family) { (e.target as HTMLInputElement).value = ''; return }
uploading.value = true
try {
const fd = new FormData(); fd.append('file', file); fd.append('family', family)
await client.post(`/companies/${cidPath()}/card-template/font`, fd)
tpl.value.fonts = [...(tpl.value.fonts ?? []).filter((f) => f !== family), family]
} catch { alert('Upload fehlgeschlagen (nur TTF/OTF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function previewPdf() {
if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return }
const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob' })
window.open(URL.createObjectURL(res.data as Blob), '_blank')
}
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); undo() }
}
onMounted(() => { load(); window.addEventListener('keydown', onKey) })
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<section>
<div class="page-head">
<div><h1>Visitenkarten-Editor</h1><p class="muted">Layout pro Firma · druckfertiges PDF</p></div>
<div class="head-actions">
<select v-if="companies.length > 1" class="input" v-model="selectedCompanyId" @change="loadTemplate">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select>
<button class="btn btn-ghost" :disabled="!history.length" @click="undo" title="Rückgängig (Strg+Z)">↶</button>
<button class="btn btn-ghost" @click="previewPdf">PDF-Vorschau</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
</div>
</div>
<p v-if="saved" class="ok">✓ Vorlage gespeichert</p>
<div v-if="loading" class="muted">Lädt…</div>
<div v-else-if="tpl" class="editor">
<!-- Werkzeugleiste + Canvas -->
<div>
<div class="toolbar">
<div class="tabs">
<button :class="['tab', { active: side === 'front' }]" @click="side = 'front'; selectedIndex = null">Vorderseite</button>
<button :class="['tab', { active: side === 'back' }]" @click="side = 'back'; selectedIndex = null">Rückseite</button>
</div>
<div class="adds">
<button class="btn btn-soft btn-sm" @click="add('field')">+ Feld</button>
<button class="btn btn-soft btn-sm" @click="add('text')">+ Text</button>
<button class="btn btn-soft btn-sm" @click="add('qr')">+ QR</button>
<button class="btn btn-soft btn-sm" @click="add('image')">+ Logo</button>
<button class="btn btn-soft btn-sm" @click="add('rect')">+ Fläche</button>
<button class="btn btn-soft btn-sm" @click="add('line')">+ Linie</button>
</div>
</div>
<div class="assets">
<label class="btn btn-ghost btn-sm" :class="{ disabled: uploading }">
Hintergrund-PDF…
<input type="file" accept="application/pdf,.pdf" hidden @change="uploadBackground" />
</label>
<template v-if="tpl.hasBackground">
<span class="badge badge-active">Hintergrund aktiv</span>
<button class="btn btn-ghost btn-sm" @click="removeBackground">Entfernen</button>
</template>
<span class="sep"></span>
<label class="btn btn-ghost btn-sm" :class="{ disabled: uploading }">
Schrift…
<input type="file" accept=".ttf,.otf" hidden @change="uploadFont" />
</label>
<span v-for="f in (tpl.fonts || [])" :key="f" class="chip">{{ f }}</span>
</div>
<div class="stage">
<!-- Beschnitt-Fläche -->
<div class="bleed" :style="{ width: (tpl.widthMm + 2 * tpl.bleedMm) * SCALE + 'px', height: (tpl.heightMm + 2 * tpl.bleedMm) * SCALE + 'px' }">
<!-- Hintergrund-PDF als Bild (füllt inkl. Beschnitt) -->
<img v-if="currentBg" :src="currentBg" class="bg-img" />
<!-- Endformat (Trim) -->
<div class="trim" :class="{ transparent: !!currentBg }" :style="{ left: tpl.bleedMm * SCALE + 'px', top: tpl.bleedMm * SCALE + 'px', width: tpl.widthMm * SCALE + 'px', height: tpl.heightMm * SCALE + 'px' }" @mousedown.self="selectedIndex = null">
<!-- Hinweis nur, solange Hintergrund-PDF noch nicht gerendert ist -->
<div v-if="tpl.hasBackground && !currentBg" class="bg-note">Hintergrund-PDF wird geladen…</div>
<!-- Sicherheitsabstand -->
<div class="safe" :style="{ inset: tpl.safeMm * SCALE + 'px' }"></div>
<!-- Elemente -->
<div
v-for="(el, i) in els" :key="i"
class="el" :class="{ sel: i === selectedIndex, ['t-' + el.type]: true }"
:style="elStyle(el)"
@mousedown="startDrag($event, i)"
>
<template v-if="el.type === 'field' || el.type === 'text'">{{ elText(el) }}</template>
<div v-else-if="el.type === 'qr'" class="ph-qr">QR</div>
<img v-else-if="el.type === 'image' && branding.logoUrl" :src="branding.logoUrl" class="logo-img" />
<div v-else-if="el.type === 'image'" class="ph-logo">Logo</div>
<div v-if="i === selectedIndex" class="handle" @mousedown.stop="startResize($event, i)"></div>
</div>
</div>
</div>
<div class="legend muted">
<span class="lg-bleed"></span> Beschnitt
<span class="lg-trim"></span> Endformat
<span class="lg-safe"></span> Sicherheit
</div>
</div>
</div>
<!-- Eigenschaften -->
<div class="card props">
<h3>Eigenschaften</h3>
<p v-if="!selected" class="muted small">Element auswählen oder hinzufügen.</p>
<template v-if="selected">
<div class="field"><label>Typ</label><input class="input" :value="selected.type" disabled /></div>
<div class="field" v-if="selected.type === 'field'">
<label>Datenfeld</label>
<select class="input" v-model="selected.binding">
<option v-for="[v, l] in BINDINGS" :key="v" :value="v">{{ l }}</option>
</select>
</div>
<div class="field" v-if="selected.type === 'text'"><label>Text</label><input class="input" v-model="selected.text" /></div>
<div class="grid2">
<div class="field"><label>X (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.x" /></div>
<div class="field"><label>Y (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.y" /></div>
</div>
<div class="grid2" v-if="['rect','qr','image','line'].includes(selected.type)">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.w" /></div>
<div class="field" v-if="selected.type !== 'line'"><label>Höhe (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.h" /></div>
</div>
<template v-if="selected.type === 'field' || selected.type === 'text'">
<div class="grid2">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.w" /></div>
<div class="field"><label>Schrift (pt)</label><input class="input" type="number" step="0.5" v-model.number="selected.fontSize" /></div>
</div>
<div class="grid2">
<div class="field"><label>Ausrichtung</label>
<select class="input" v-model="selected.align"><option value="L">Links</option><option value="C">Mitte</option><option value="R">Rechts</option></select>
</div>
<div class="field"><label>Fett</label><select class="input" v-model="selected.bold"><option :value="false">Nein</option><option :value="true">Ja</option></select></div>
</div>
<div class="field">
<label>Schriftart</label>
<select class="input" v-model="selected.fontFamily">
<option value="">Standard (Helvetica)</option>
<option value="times">Times</option>
<option value="courier">Courier</option>
<option v-for="f in (tpl.fonts || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
</template>
<div class="field" v-if="selected.type !== 'qr' && selected.type !== 'image'">
<label>Farbe</label>
<select class="input" :value="colorMode(selected)" @change="setColorMode(selected, ($event.target as HTMLSelectElement).value)">
<option value="primary">Primärfarbe</option>
<option value="dark">Dunkel</option>
<option value="text">Text</option>
<option value="white">Weiß</option>
<option value="custom">Eigene…</option>
</select>
<input v-if="colorMode(selected) === 'custom'" type="color" class="color-pick"
:value="(selected[COLOR_KEY(selected)] && selected[COLOR_KEY(selected)].hex) || '#000000'"
@input="setColorHex(selected, ($event.target as HTMLInputElement).value)" />
</div>
<button class="btn btn-ghost btn-sm del" @click="removeSelected">Element löschen</button>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.page-head .muted { margin: .2rem 0 0; }
.head-actions { display: flex; gap: .6rem; align-items: center; }
.ok { color: var(--success); font-weight: 600; font-size: .88rem; margin: 0 0 .8rem; }
.editor { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1.4rem; align-items: start; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.tabs { display: flex; gap: .3rem; }
.tab { padding: .5rem .9rem; border: 1px solid var(--line); background: #fff; border-radius: var(--radius-sm); font-weight: 600; cursor: pointer; font-size: .85rem; }
.tab.active { background: var(--dark); color: #fff; border-color: var(--dark); }
.adds { display: flex; gap: .35rem; flex-wrap: wrap; }
.assets { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; padding: .7rem .9rem; background: #fff; border: 1px solid var(--line); border-radius: var(--radius-sm); }
.assets .btn.disabled { opacity: .6; pointer-events: none; }
.assets .sep { width: 1px; height: 20px; background: var(--line); margin: 0 .3rem; }
.chip { background: var(--psc-orange-soft); color: var(--psc-orange-dark); padding: .15rem .6rem; border-radius: 999px; font-size: .78rem; font-weight: 600; }
.bg-note { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #b3b3b3; font-size: 12px; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 8px, #f2f2f2 8px, #f2f2f2 16px); pointer-events: none; }
.bg-note small { font-size: 10px; }
.stage { background: #ececec; border-radius: var(--radius); padding: 1.5rem; display: flex; flex-direction: column; align-items: center; overflow-x: auto; }
.bleed { position: relative; background: #fbe9da; box-shadow: var(--shadow-sm); }
.bg-img { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; }
.trim { position: absolute; background: #fff; overflow: hidden; z-index: 1; }
.trim.transparent { background: transparent; }
.handle { position: absolute; right: -5px; bottom: -5px; width: 10px; height: 10px; background: var(--psc-orange); border: 2px solid #fff; border-radius: 2px; cursor: nwse-resize; z-index: 5; }
.safe { position: absolute; border: 1px dashed #c9c9c9; pointer-events: none; }
.el { position: absolute; cursor: move; user-select: none; }
.el.sel { outline: 2px solid var(--psc-orange); outline-offset: 1px; }
.t-field, .t-text { overflow: hidden; }
.ph-qr { width: 100%; height: 100%; background: repeating-conic-gradient(#222 0% 25%, #fff 0% 50%) 50% / 6px 6px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
.ph-logo { width: 100%; height: 100%; border: 1px dashed #bbb; display: flex; align-items: center; justify-content: center; color: #999; font-size: 10px; }
.logo-img { width: 100%; height: 100%; object-fit: contain; }
.legend { display: flex; gap: .8rem; align-items: center; margin-top: 1rem; font-size: .75rem; }
.legend span:first-child, .legend span { display: inline-block; }
.lg-bleed, .lg-trim, .lg-safe { width: 14px; height: 10px; border-radius: 2px; vertical-align: middle; margin-right: -.4rem; }
.lg-bleed { background: #fbe9da; } .lg-trim { background: #fff; border: 1px solid #ccc; } .lg-safe { border: 1px dashed #c9c9c9; }
.props { padding: 1.2rem; }
.props h3 { margin-bottom: .8rem; }
.props .field { min-width: 0; }
.props :deep(.input) { width: 100%; min-width: 0; }
.small { font-size: .82rem; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.grid2 > .field { min-width: 0; }
.color-pick { margin-top: .4rem; width: 100%; height: 34px; border: 1px solid #e0e0e0; border-radius: 8px; cursor: pointer; }
.del { margin-top: .8rem; color: var(--danger); }
</style>