- 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>
489 lines
24 KiB
Vue
489 lines
24 KiB
Vue
<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>
|